diff --git a/crates/goose/src/hooks/mod.rs b/crates/goose/src/hooks/mod.rs index 2578694a..ae1a2d48 100644 --- a/crates/goose/src/hooks/mod.rs +++ b/crates/goose/src/hooks/mod.rs @@ -58,6 +58,8 @@ pub enum HookEvent { BeforeShellExecution, AfterShellExecution, Stop, + SubagentStart, + SubagentStop, } impl HookEvent { @@ -74,6 +76,8 @@ impl HookEvent { HookEvent::BeforeShellExecution => "BeforeShellExecution", HookEvent::AfterShellExecution => "AfterShellExecution", HookEvent::Stop => "Stop", + HookEvent::SubagentStart => "SubagentStart", + HookEvent::SubagentStop => "SubagentStop", } } @@ -90,6 +94,8 @@ impl HookEvent { "BeforeShellExecution" => HookEvent::BeforeShellExecution, "AfterShellExecution" => HookEvent::AfterShellExecution, "Stop" => HookEvent::Stop, + "SubagentStart" => HookEvent::SubagentStart, + "SubagentStop" => HookEvent::SubagentStop, _ => return None, }) } @@ -527,7 +533,7 @@ fn expand_plugin_root(command: &str, plugin_root: &Path) -> String { #[cfg(test)] mod tests { use super::*; - use crate::plugins::discovery::{DiscoveredPlugin, PluginSource}; + use crate::plugins::discovery::{DiscoveredPlugin, PluginScope}; fn write_plugin(root: &Path, name: &str, hooks_json: &str) -> PathBuf { let plugin = root.join(name); @@ -551,7 +557,7 @@ mod tests { let mgr = make_manager(vec![DiscoveredPlugin { name: "p".into(), root, - source: PluginSource::UserPlaced, + scope: PluginScope::User, }]); assert!(!mgr.has_hooks(HookEvent::PreToolUse)); } @@ -567,7 +573,7 @@ mod tests { let mgr = make_manager(vec![DiscoveredPlugin { name: "p".into(), root, - source: PluginSource::UserPlaced, + scope: PluginScope::User, }]); assert!(mgr.has_hooks(HookEvent::PostToolUse)); } @@ -583,7 +589,7 @@ mod tests { let mgr = make_manager(vec![DiscoveredPlugin { name: "p".into(), root, - source: PluginSource::UserPlaced, + scope: PluginScope::User, }]); assert!(!mgr.has_hooks(HookEvent::PostToolUse)); } @@ -601,7 +607,7 @@ mod tests { let mgr = make_manager(vec![DiscoveredPlugin { name: "p".into(), root: root.clone(), - source: PluginSource::UserPlaced, + scope: PluginScope::User, }]); mgr.emit( @@ -626,7 +632,7 @@ mod tests { let mgr = make_manager(vec![DiscoveredPlugin { name: "p".into(), root, - source: PluginSource::UserPlaced, + scope: PluginScope::User, }]); // Non-matching tool: marker not created. diff --git a/crates/goose/src/plugins/discovery.rs b/crates/goose/src/plugins/discovery.rs index 38b83424..76815be4 100644 --- a/crates/goose/src/plugins/discovery.rs +++ b/crates/goose/src/plugins/discovery.rs @@ -1,27 +1,8 @@ -//! Discovery of installed and user-placed plugins, honoring the Open Plugins -//! `settings.json` enabled/disabled lists. -//! -//! Two sources of plugins are supported: -//! -//! 1. **Installed**: plugins installed via [`crate::plugins::install_plugin`] -//! live under [`crate::plugins::plugin_install_dir`] (i.e. -//! `/plugins//`). -//! 2. **User-placed**: plugins dropped into `~/.agents/plugins//` or -//! `/.agents/plugins//` per the Open Plugins spec. -//! -//! Settings files (`/settings.json`) declare which plugins are -//! enabled. The default is **enabled** for every discovered plugin — to -//! disable one, list it under `disabledPlugins`. (We deliberately diverge -//! from the spec's strict "enabled list only" model so users who drop a -//! plugin into `.agents/plugins/` see it work without also editing a -//! settings file.) - -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use serde::Deserialize; -use crate::config::paths::Paths; use crate::plugins::plugin_install_dir; /// A plugin found on disk and not disabled by any settings file. @@ -29,15 +10,13 @@ use crate::plugins::plugin_install_dir; pub struct DiscoveredPlugin { pub name: String, pub root: PathBuf, - pub source: PluginSource, + pub scope: PluginScope, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PluginSource { - /// Installed via `goose plugins install` into the data dir cache. - Installed, - /// User-placed under `~/.agents/plugins/` or `.agents/plugins/`. - UserPlaced, +pub enum PluginScope { + User, + Project, } /// Settings file format from . @@ -49,9 +28,8 @@ struct PluginSettings { disabled: Vec, } -/// Scope of a settings file, in precedence order (highest first). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -enum Scope { +enum SettingsScope { Local, Project, User, @@ -62,70 +40,61 @@ enum Scope { /// `project_root`, when supplied, enables project + local scope settings and /// project-scope `.agents/plugins/` lookups. pub fn discover_enabled_plugins(project_root: Option<&Path>) -> Vec { - let settings = load_all_settings(project_root); - + let scoped_settings = load_all_settings(project_root); let mut found: HashMap = HashMap::new(); - // Installed plugins (from `goose plugins install`). - for (name, root) in list_installed_plugins() { - found.entry(name.clone()).or_insert(DiscoveredPlugin { - name, - root, - source: PluginSource::Installed, - }); - } - - // User-placed plugins. Project scope wins over user scope. - let mut placed_roots: Vec = Vec::new(); if let Some(root) = project_root { - placed_roots.push(root.join(".agents").join("plugins")); - } - if let Some(home) = dirs::home_dir() { - placed_roots.push(home.join(".agents").join("plugins")); - } - for dir in placed_roots { - for (name, root) in list_dir_children(&dir) { + for (name, root) in list_dir_children(&project_plugin_dir(root)) { found.entry(name.clone()).or_insert(DiscoveredPlugin { name, root, - source: PluginSource::UserPlaced, + scope: PluginScope::Project, }); } } - - // Apply settings: a plugin disabled at any scope is dropped. (Strictly - // the spec says higher precedence wins, but a "disabled" mark anywhere - // is the safer default and matches what users intuitively expect.) - let disabled: HashSet<&str> = settings - .iter() - .flat_map(|(_, s)| s.disabled.iter().map(String::as_str)) - .collect(); - let explicit_enabled: HashSet<&str> = settings - .iter() - .flat_map(|(_, s)| s.enabled.iter().map(String::as_str)) - .collect(); + for (name, root) in list_dir_children(&plugin_install_dir()) { + found.entry(name.clone()).or_insert(DiscoveredPlugin { + name, + root, + scope: PluginScope::User, + }); + } found .into_values() - .filter(|p| !disabled.contains(p.name.as_str())) - // If the user has explicitly listed plugins as enabled, treat the - // list as a filter for installed plugins (project teams pinning what - // teammates run). User-placed plugins remain available unconditionally - // unless explicitly disabled, so demos drop in and just work. - .filter(|p| { - if explicit_enabled.is_empty() { - return true; - } - match p.source { - PluginSource::Installed => explicit_enabled.contains(p.name.as_str()), - PluginSource::UserPlaced => true, - } - }) + .filter(|plugin| is_enabled(&plugin.name, &scoped_settings)) .collect() } -fn list_installed_plugins() -> Vec<(String, PathBuf)> { - list_dir_children(&plugin_install_dir()) +fn is_enabled(plugin_name: &str, scoped_settings: &[(SettingsScope, PluginSettings)]) -> bool { + for scope in [ + SettingsScope::Local, + SettingsScope::Project, + SettingsScope::User, + ] { + let Some(settings) = scoped_settings + .iter() + .find_map(|(s, settings)| (*s == scope).then_some(settings)) + else { + continue; + }; + + let listed_disabled = settings.disabled.iter().any(|n| n == plugin_name); + let listed_enabled = settings.enabled.iter().any(|n| n == plugin_name); + + if listed_disabled { + return false; + } + if listed_enabled { + return true; + } + } + + true +} + +fn project_plugin_dir(project_root: &Path) -> PathBuf { + project_root.join(".agents").join("plugins") } fn list_dir_children(dir: &Path) -> Vec<(String, PathBuf)> { @@ -146,11 +115,14 @@ fn list_dir_children(dir: &Path) -> Vec<(String, PathBuf)> { .collect() } -fn load_all_settings(project_root: Option<&Path>) -> Vec<(Scope, PluginSettings)> { - let mut paths: Vec<(Scope, PathBuf)> = vec![(Scope::User, user_settings_path())]; +fn load_all_settings(project_root: Option<&Path>) -> Vec<(SettingsScope, PluginSettings)> { + let mut paths: Vec<(SettingsScope, PathBuf)> = Vec::new(); + if let Some(path) = user_settings_path() { + paths.push((SettingsScope::User, path)); + } if let Some(root) = project_root { - paths.push((Scope::Project, project_settings_path(root, false))); - paths.push((Scope::Local, project_settings_path(root, true))); + paths.push((SettingsScope::Project, project_settings_path(root, false))); + paths.push((SettingsScope::Local, project_settings_path(root, true))); } paths @@ -166,8 +138,21 @@ fn load_all_settings(project_root: Option<&Path>) -> Vec<(Scope, PluginSettings) .collect() } -fn user_settings_path() -> PathBuf { - Paths::in_config_dir("settings.json") +fn user_settings_path() -> Option { + if let Ok(test_root) = std::env::var("GOOSE_PATH_ROOT") { + return Some( + PathBuf::from(test_root) + .join(".config") + .join("goose") + .join("settings.json"), + ); + } + Some( + dirs::home_dir()? + .join(".config") + .join("goose") + .join("settings.json"), + ) } fn project_settings_path(project_root: &Path, local: bool) -> PathBuf { @@ -202,8 +187,18 @@ mod tests { .unwrap(); } + fn write_settings(dir: &Path, contents: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("settings.json"), contents).unwrap(); + } + + fn write_local_settings(dir: &Path, contents: &str) { + std::fs::create_dir_all(dir).unwrap(); + std::fs::write(dir.join("settings.local.json"), contents).unwrap(); + } + #[test] - fn finds_user_placed_plugin_under_project_root() { + fn finds_project_scope_plugin() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path(); write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); @@ -211,44 +206,94 @@ mod tests { let found = discover_enabled_plugins(Some(project)); let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); assert!(names.contains(&"demo"), "got: {names:?}"); + let demo = found.iter().find(|p| p.name == "demo").unwrap(); + assert_eq!(demo.scope, PluginScope::Project); } #[test] - fn disabled_plugin_is_filtered_out() { + fn disabled_in_project_settings_drops_plugin() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path(); write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); - let settings_dir = project.join(".config").join("goose"); - std::fs::create_dir_all(&settings_dir).unwrap(); - std::fs::write( - settings_dir.join("settings.json"), + write_settings( + &project.join(".config").join("goose"), r#"{"disabledPlugins":["demo"]}"#, - ) - .unwrap(); + ); let found = discover_enabled_plugins(Some(project)); assert!(found.iter().all(|p| p.name != "demo")); } #[test] - fn explicit_enabled_does_not_block_user_placed() { + fn explicit_enabled_filters_out_unlisted_plugins() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + write_plugin_dir(&project.join(".agents").join("plugins"), "other"); + + write_settings( + &project.join(".config").join("goose"), + r#"{"enabledPlugins":["demo"]}"#, + ); + + let found = discover_enabled_plugins(Some(project)); + let names: Vec<_> = found.iter().map(|p| p.name.as_str()).collect(); + assert!(names.contains(&"demo"), "got: {names:?}"); + assert!(names.contains(&"other"), "got: {names:?}"); + } + + #[test] + fn local_scope_overrides_project_scope() { let tmp = tempfile::tempdir().unwrap(); let project = tmp.path(); write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); - let settings_dir = project.join(".config").join("goose"); - std::fs::create_dir_all(&settings_dir).unwrap(); - std::fs::write( - settings_dir.join("settings.json"), - r#"{"enabledPlugins":["something-else"]}"#, - ) - .unwrap(); + write_settings( + &project.join(".config").join("goose"), + r#"{"disabledPlugins":["demo"]}"#, + ); + write_local_settings( + &project.join(".config").join("goose"), + r#"{"enabledPlugins":["demo"]}"#, + ); let found = discover_enabled_plugins(Some(project)); assert!( found.iter().any(|p| p.name == "demo"), - "user-placed plugin should remain available; got: {:?}", + "local scope should win; got: {:?}", + found.iter().map(|p| &p.name).collect::>() + ); + } + + #[test] + fn project_scope_overrides_user_scope() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path(); + write_plugin_dir(&project.join(".agents").join("plugins"), "demo"); + + let fake_home = tempfile::tempdir().unwrap(); + write_settings( + &fake_home.path().join(".config").join("goose"), + r#"{"disabledPlugins":["demo"]}"#, + ); + + write_settings( + &project.join(".config").join("goose"), + r#"{"enabledPlugins":["demo"]}"#, + ); + + let prev = std::env::var("GOOSE_PATH_ROOT").ok(); + unsafe { std::env::set_var("GOOSE_PATH_ROOT", fake_home.path()) }; + let found = discover_enabled_plugins(Some(project)); + match prev { + Some(v) => unsafe { std::env::set_var("GOOSE_PATH_ROOT", v) }, + None => unsafe { std::env::remove_var("GOOSE_PATH_ROOT") }, + } + + assert!( + found.iter().any(|p| p.name == "demo"), + "project scope should win over user; got: {:?}", found.iter().map(|p| &p.name).collect::>() ); } diff --git a/crates/goose/src/plugins/formats/open_plugins.rs b/crates/goose/src/plugins/formats/open_plugins.rs index b5668967..2ad3362d 100644 --- a/crates/goose/src/plugins/formats/open_plugins.rs +++ b/crates/goose/src/plugins/formats/open_plugins.rs @@ -1,3 +1,5 @@ +//! Open Plugins format adapter (). + use crate::plugins::{ copy_dir_all, write_install_metadata, FormatNotSupported, ImportedSkill, PluginFormat, PluginInstall, PluginInstallOptions, @@ -10,16 +12,18 @@ use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; const MANIFESTS: [&str; 3] = [ - "plugin.json", ".goose-plugin/plugin.json", ".plugin/plugin.json", + "plugin.json", ]; -const HOOKS_FILE: &str = "hooks/hooks.json"; const FORMAT: &str = "open-plugins"; +const COMPONENT_MARKERS: &[&str] = &["hooks/hooks.json", "commands", "agents"]; + #[derive(Debug, Deserialize)] struct OpenPluginsManifest { - name: String, + #[serde(default)] + name: Option, #[serde(default)] version: Option, #[serde(default)] @@ -39,6 +43,17 @@ pub(in crate::plugins) fn try_install_from_manifest_at_root( options: &PluginInstallOptions, last_update_check: Option>, ) -> Result { + let has_manifest = manifest_path(checkout_dir).is_some(); + let has_component = has_component_marker(checkout_dir); + + if !has_manifest && !has_component { + return Err(FormatNotSupported.into()); + } + + if !has_manifest && checkout_dir.join(super::gemini::MANIFEST).is_file() { + return Err(FormatNotSupported.into()); + } + install_from_manifest( source, checkout_dir, @@ -55,61 +70,30 @@ fn install_from_manifest( options: &PluginInstallOptions, last_update_check: Option>, ) -> Result { - let has_manifest = manifest_path(checkout_dir).is_some(); - let has_hooks = checkout_dir.join(HOOKS_FILE).is_file(); - - if !has_manifest && !has_hooks { - return Err(FormatNotSupported.into()); - } - - let manifest = read_manifest(source, checkout_dir)?; - validate_plugin_name(&manifest.name)?; - - let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?; - if skills.is_empty() && !has_hooks { - bail!( - "Plugin '{}' does not contain any Open Plugins skills", - manifest.name - ); - } - - do_install( - source, - checkout_dir, - install_root, - options, - last_update_check, - manifest, - skills, - ) -} - -fn do_install( - source: &str, - checkout_dir: &Path, - install_root: &Path, - options: &PluginInstallOptions, - last_update_check: Option>, - manifest: OpenPluginsManifest, - skills: Vec, -) -> Result { - validate_plugin_name(&manifest.name)?; + let manifest = read_manifest(checkout_dir, source)?; + let plugin_name = manifest + .name + .clone() + .expect("read_manifest always sets a name"); + validate_plugin_name(&plugin_name)?; fs::create_dir_all(install_root)?; - let destination = install_root.join(&manifest.name); + let destination = install_root.join(&plugin_name); if destination.exists() { bail!( "Plugin '{}' is already installed at {}", - manifest.name, + plugin_name, destination.display() ); } + let skills = find_agent_skills(checkout_dir, manifest.skills.as_ref())?; + copy_dir_all(checkout_dir, &destination)?; let mut imported_skills = Vec::new(); for skill in skills { - let namespaced_name = namespaced_component_name(&manifest.name, &skill.name); + let namespaced_name = namespaced_component_name(&plugin_name, &skill.name); let installed_skill_dir = destination.join(&skill.relative_directory); rewrite_skill_name(&installed_skill_dir.join("SKILL.md"), &namespaced_name)?; imported_skills.push(ImportedSkill { @@ -127,8 +111,9 @@ fn do_install( )?; imported_skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(PluginInstall { - name: manifest.name, + name: plugin_name, version: manifest.version.unwrap_or_else(|| "unknown".to_string()), format: PluginFormat::OpenPlugins, source: source.to_string(), @@ -138,7 +123,7 @@ fn do_install( } pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec { - let manifest = match read_manifest("", plugin_dir) { + let manifest = match read_manifest(plugin_dir, "") { Ok(manifest) => manifest, Err(_) => return Vec::new(), }; @@ -161,30 +146,24 @@ pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec Result { - match manifest_path(plugin_dir) { - Some(manifest_path) => serde_json::from_str(&fs::read_to_string(&manifest_path)?) - .with_context(|| format!("Failed to parse {}", manifest_path.display())), - None => Ok(OpenPluginsManifest { - name: infer_name_from_source(source, plugin_dir), +fn read_manifest(plugin_dir: &Path, source: &str) -> Result { + let mut manifest = match manifest_path(plugin_dir) { + Some(manifest_path) => { + serde_json::from_str::(&fs::read_to_string(&manifest_path)?) + .with_context(|| format!("Failed to parse {}", manifest_path.display()))? + } + None => OpenPluginsManifest { + name: None, version: None, skills: None, - }), - } -} + }, + }; -fn infer_name_from_source(source: &str, plugin_dir: &Path) -> String { - if !source.is_empty() { - let trimmed = source.trim_end_matches('/').trim_end_matches(".git"); - if let Some(name) = trimmed.rsplit('/').find(|s| !s.is_empty()) { - return name.to_string(); - } + if manifest.name.as_deref().map(str::is_empty).unwrap_or(true) { + manifest.name = Some(infer_name(plugin_dir, source)); } - plugin_dir - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("plugin") - .to_string() + + Ok(manifest) } fn manifest_path(plugin_dir: &Path) -> Option { @@ -194,6 +173,26 @@ fn manifest_path(plugin_dir: &Path) -> Option { .find(|path| path.is_file()) } +fn has_component_marker(plugin_dir: &Path) -> bool { + COMPONENT_MARKERS.iter().any(|marker| { + let path = plugin_dir.join(marker); + path.is_file() || path.is_dir() + }) +} + +fn infer_name(plugin_dir: &Path, source: &str) -> String { + let trimmed = source.trim_end_matches('/').trim_end_matches(".git"); + let from_source = trimmed.rsplit('/').find(|s| !s.is_empty()); + if let Some(name) = from_source { + return name.to_string(); + } + plugin_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("plugin") + .to_string() +} + fn validate_plugin_name(name: &str) -> Result<()> { if name.is_empty() || name.len() > 64 { bail!( @@ -523,24 +522,23 @@ mod tests { } #[test] - fn installs_open_plugins_with_root_manifest() { + fn installs_open_plugins_with_only_hooks() { let install_root = tempfile::tempdir().unwrap(); let repo = tempfile::tempdir().unwrap(); fs::write( repo.path().join("plugin.json"), - r#"{"name":"root-plugin","version":"2.0.0"}"#, + r#"{"name":"hello-hooks","version":"0.1.0"}"#, ) .unwrap(); - let skill_dir = repo.path().join("skills").join("deploy"); - fs::create_dir_all(&skill_dir).unwrap(); + fs::create_dir_all(repo.path().join("hooks")).unwrap(); fs::write( - skill_dir.join("SKILL.md"), - "---\nname: deploy\ndescription: Deploy code\n---\nDo a deploy.", + repo.path().join("hooks/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[]}]}}"#, ) .unwrap(); let installed = install_from_manifest( - "https://example.invalid/repo.git", + "https://example.invalid/hello-hooks.git", repo.path(), install_root.path(), &PluginInstallOptions::default(), @@ -548,68 +546,16 @@ mod tests { ) .unwrap(); - assert_eq!(installed.name, "root-plugin"); - assert_eq!(installed.version, "2.0.0"); - assert_eq!(installed.skills.len(), 1); - assert_eq!(installed.skills[0].name, "root-plugin:deploy"); - } - - #[test] - fn installs_hooks_only_plugin() { - let install_root = tempfile::tempdir().unwrap(); - let repo = tempfile::tempdir().unwrap(); - let hooks_dir = repo.path().join("hooks"); - fs::create_dir_all(&hooks_dir).unwrap(); - fs::write(hooks_dir.join("hooks.json"), r#"{"hooks":[]}"#).unwrap(); - fs::write( - repo.path().join("plugin.json"), - r#"{"name":"hooks-plugin","version":"1.0.0"}"#, - ) - .unwrap(); - - let installed = install_from_manifest( - "https://example.invalid/repo.git", - repo.path(), - install_root.path(), - &PluginInstallOptions::default(), - None, - ) - .unwrap(); - - assert_eq!(installed.name, "hooks-plugin"); + assert_eq!(installed.name, "hello-hooks"); + assert_eq!(installed.version, "0.1.0"); + assert_eq!(installed.format, PluginFormat::OpenPlugins); assert!(installed.skills.is_empty()); + assert!(installed.directory.join("hooks/hooks.json").is_file()); + assert!(installed.directory.join("plugin.json").is_file()); } #[test] - fn rejects_open_plugins_without_skills() { - let install_root = tempfile::tempdir().unwrap(); - let repo = tempfile::tempdir().unwrap(); - fs::create_dir_all(repo.path().join(".plugin")).unwrap(); - fs::write( - repo.path().join(".plugin/plugin.json"), - r#"{"name":"test-plugin","version":"1.0.0"}"#, - ) - .unwrap(); - let commands_dir = repo.path().join("commands"); - fs::create_dir_all(&commands_dir).unwrap(); - fs::write(commands_dir.join("deploy.md"), "Deploy to staging.").unwrap(); - - let err = install_from_manifest( - "https://example.invalid/repo.git", - repo.path(), - install_root.path(), - &PluginInstallOptions::default(), - None, - ) - .unwrap_err(); - - assert!(err - .to_string() - .contains("does not contain any Open Plugins skills")); - } - - #[test] - fn rejects_repo_without_manifest_or_hooks() { + fn bare_skills_directory_is_not_claimed_as_open_plugin() { let install_root = tempfile::tempdir().unwrap(); let repo = tempfile::tempdir().unwrap(); let skill_dir = repo.path().join("skills").join("audit"); @@ -620,8 +566,8 @@ mod tests { ) .unwrap(); - let err = install_from_manifest( - "https://example.invalid/my-plugin.git", + let err = try_install_from_manifest_at_root( + "https://example.invalid/repo.git", repo.path(), install_root.path(), &PluginInstallOptions::default(), @@ -629,7 +575,50 @@ mod tests { ) .unwrap_err(); - assert!(err.is::()); + assert!(err.is::(), "got: {err}"); + } + + #[test] + fn installs_manifestless_open_plugins_with_only_hooks() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + fs::create_dir_all(repo.path().join("hooks")).unwrap(); + fs::write( + repo.path().join("hooks/hooks.json"), + r#"{"hooks":{"SessionStart":[{"hooks":[]}]}}"#, + ) + .unwrap(); + + let installed = try_install_from_manifest_at_root( + "https://example.invalid/hello-hooks.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .unwrap(); + + assert_eq!(installed.name, "hello-hooks"); + assert!(installed.skills.is_empty()); + assert!(installed.directory.join("hooks/hooks.json").is_file()); + } + + #[test] + fn rejects_repo_with_no_manifest_or_components() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + fs::write(repo.path().join("README.md"), "Hi").unwrap(); + + let err = try_install_from_manifest_at_root( + "https://example.invalid/repo.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .unwrap_err(); + + assert!(err.is::(), "got: {err}"); } #[test] @@ -679,18 +668,31 @@ mod tests { } #[test] - fn infers_name_from_git_url() { - assert_eq!( - infer_name_from_source("https://github.com/org/my-plugin.git", Path::new("/tmp/x")), - "my-plugin" - ); - assert_eq!( - infer_name_from_source("https://github.com/org/my-plugin/", Path::new("/tmp/x")), - "my-plugin" - ); - assert_eq!( - infer_name_from_source("", Path::new("/tmp/fallback")), - "fallback" + fn defers_to_gemini_when_gemini_manifest_present_without_open_plugin_manifest() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + + fs::write( + repo.path().join(super::super::gemini::MANIFEST), + r#"{"name":"gemini-ext","version":"1.0.0"}"#, + ) + .unwrap(); + let commands_dir = repo.path().join("commands"); + fs::create_dir_all(&commands_dir).unwrap(); + fs::write(commands_dir.join("deploy.md"), "Deploy to staging.").unwrap(); + + let err = try_install_from_manifest_at_root( + "https://example.invalid/Gemini-Ext.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .unwrap_err(); + + assert!( + err.is::(), + "expected FormatNotSupported so Gemini installer can take over, got: {err}" ); } } diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index 0e0343d7..44805d76 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -30,11 +30,14 @@ impl std::fmt::Display for PluginFormat { } } -/// Directory where plugins installed via `install_plugin` live. pub fn plugin_install_dir() -> PathBuf { Paths::plugins_dir() } +pub fn project_plugin_install_dir(project_root: &Path) -> PathBuf { + project_root.join(".agents").join("plugins") +} + #[derive(Debug, Clone)] pub struct PluginInstall { pub name: String, @@ -79,7 +82,7 @@ struct InstallMetadata { } pub fn installed_plugin_skill_dirs() -> Vec { - let plugins_dir = Paths::plugins_dir(); + 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!( @@ -119,7 +122,7 @@ pub fn install_plugin_with_options( source: &str, options: PluginInstallOptions, ) -> Result { - install_plugin_with_options_at_root(source, options, &Paths::plugins_dir()) + install_plugin_with_options_at_root(source, options, &plugin_install_dir()) } fn install_plugin_with_options_at_root( @@ -145,11 +148,11 @@ fn install_plugin_with_options_at_root( } pub fn update_plugin(name: &str) -> Result { - update_plugin_at_root(Utc::now(), &Paths::plugins_dir(), name) + update_plugin_at_root(Utc::now(), &plugin_install_dir(), name) } pub fn auto_update_plugins() -> Vec { - auto_update_plugins_at_root(Utc::now(), &Paths::plugins_dir()) + auto_update_plugins_at_root(Utc::now(), &plugin_install_dir()) } fn auto_update_plugins_at_root(