From 09d7f980013dcfff1db66980a79ae9d74be32f5c Mon Sep 17 00:00:00 2001 From: Jack Amadeo Date: Wed, 13 May 2026 14:35:27 -0400 Subject: [PATCH] plugins: add open plugins (just skills for now) (#9063) Signed-off-by: Douwe Osinga Co-authored-by: Douwe Osinga --- .../goose/src/plugins/formats/open_plugins.rs | 727 +++++++++++++++--- crates/goose/src/plugins/mod.rs | 51 +- 2 files changed, 662 insertions(+), 116 deletions(-) diff --git a/crates/goose/src/plugins/formats/open_plugins.rs b/crates/goose/src/plugins/formats/open_plugins.rs index e4de3293bf..b566896721 100644 --- a/crates/goose/src/plugins/formats/open_plugins.rs +++ b/crates/goose/src/plugins/formats/open_plugins.rs @@ -1,38 +1,35 @@ -//! Open Plugins format adapter (). -//! -//! Plugins are recognized as Open Plugins if they contain either: -//! - `plugin.json` (the spec's optional manifest), or -//! - `hooks/hooks.json` -//! -//! A bare `skills/` directory alone is not enough — it falls through to the -//! Gemini adapter, which requires its own manifest. We probe Open Plugins -//! first so a plugin that ships both a `plugin.json` and a -//! `gemini-extension.json` is treated as Open Plugins. - -use std::path::Path; - -use anyhow::{bail, Context, Result}; -use chrono::{DateTime, Utc}; -use fs_err as fs; -use serde::Deserialize; - use crate::plugins::{ 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::collections::HashSet; +use std::path::{Component, Path, PathBuf}; -const MANIFEST: &str = "plugin.json"; +const MANIFESTS: [&str; 3] = [ + "plugin.json", + ".goose-plugin/plugin.json", + ".plugin/plugin.json", +]; const HOOKS_FILE: &str = "hooks/hooks.json"; -const SKILLS_DIR: &str = "skills"; +const FORMAT: &str = "open-plugins"; -/// Optional `plugin.json` shape. We only need name + version; everything -/// else is forwarded as-is when the plugin is loaded at runtime. -#[derive(Debug, Default, Deserialize)] +#[derive(Debug, Deserialize)] struct OpenPluginsManifest { - #[serde(default)] - name: Option, + name: String, #[serde(default)] version: Option, + #[serde(default)] + skills: Option, +} + +#[derive(Debug)] +struct SkillCandidate { + name: String, + relative_directory: PathBuf, } pub(in crate::plugins) fn try_install_from_manifest_at_root( @@ -42,118 +39,658 @@ pub(in crate::plugins) fn try_install_from_manifest_at_root( options: &PluginInstallOptions, last_update_check: Option>, ) -> Result { - let manifest_path = checkout_dir.join(MANIFEST); - let hooks_path = checkout_dir.join(HOOKS_FILE); - let skills_path = checkout_dir.join(SKILLS_DIR); + install_from_manifest( + source, + checkout_dir, + install_root, + options, + last_update_check, + ) +} - let has_manifest = manifest_path.is_file(); - let has_hooks = hooks_path.is_file(); - let has_skills = skills_path.is_dir(); +fn install_from_manifest( + source: &str, + checkout_dir: &Path, + install_root: &Path, + 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: OpenPluginsManifest = if has_manifest { - serde_json::from_str(&fs::read_to_string(&manifest_path)?) - .with_context(|| format!("Failed to parse {}", manifest_path.display()))? - } else { - OpenPluginsManifest::default() - }; + let manifest = read_manifest(source, checkout_dir)?; + validate_plugin_name(&manifest.name)?; - let name = manifest - .name - .unwrap_or_else(|| infer_name_from_source(source)); - validate_plugin_name(&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 + ); + } - let version = manifest.version.unwrap_or_else(|| "0.0.0".to_string()); + 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)?; fs::create_dir_all(install_root)?; - let destination = install_root.join(&name); + let destination = install_root.join(&manifest.name); if destination.exists() { bail!( "Plugin '{}' is already installed at {}", - name, + manifest.name, destination.display() ); } 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 installed_skill_dir = destination.join(&skill.relative_directory); + rewrite_skill_name(&installed_skill_dir.join("SKILL.md"), &namespaced_name)?; + imported_skills.push(ImportedSkill { + name: namespaced_name, + directory: installed_skill_dir, + }); + } + write_install_metadata( &destination, source, - "open-plugins", + FORMAT, options.auto_update, last_update_check, )?; - let skills = if has_skills { - find_skills(&destination) - } else { - Vec::new() - }; - + imported_skills.sort_by(|a, b| a.name.cmp(&b.name)); Ok(PluginInstall { - name, - version, + name: manifest.name, + version: manifest.version.unwrap_or_else(|| "unknown".to_string()), format: PluginFormat::OpenPlugins, source: source.to_string(), directory: destination, - skills, + skills: imported_skills, }) } -fn infer_name_from_source(source: &str) -> String { - let trimmed = source.trim_end_matches('/').trim_end_matches(".git"); - trimmed - .rsplit('/') - .find(|s| !s.is_empty()) +pub(in crate::plugins) fn installed_skill_dirs(plugin_dir: &Path) -> Vec { + let manifest = match read_manifest("", plugin_dir) { + Ok(manifest) => manifest, + Err(_) => return Vec::new(), + }; + + let mut dirs = Vec::new(); + if !plugin_dir.join("skills").is_dir() + && manifest.skills.is_none() + && plugin_dir.join("SKILL.md").is_file() + { + dirs.push(plugin_dir.to_path_buf()); + } + + dirs.extend( + skill_root_directories(plugin_dir, manifest.skills.as_ref()) + .unwrap_or_default() + .into_iter() + .filter(|path| path.is_dir()), + ); + + dedupe_paths(dirs) +} + +fn read_manifest(source: &str, plugin_dir: &Path) -> 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), + 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(); + } + } + plugin_dir + .file_name() + .and_then(|name| name.to_str()) .unwrap_or("plugin") .to_string() } +fn manifest_path(plugin_dir: &Path) -> Option { + MANIFESTS + .iter() + .map(|relative| plugin_dir.join(relative)) + .find(|path| path.is_file()) +} + fn validate_plugin_name(name: &str) -> Result<()> { - if name.is_empty() { - bail!("Plugin name must not be empty"); - } - if !name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { + if name.is_empty() || name.len() > 64 { bail!( - "Invalid plugin name '{}'. Names may only contain letters, numbers, dashes, and underscores", + "Invalid Open Plugins name '{}'. Names must be 1-64 characters", name ); } + + if !name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '.') + { + bail!( + "Invalid Open Plugins name '{}'. Names may only contain lowercase letters, numbers, dashes, and periods", + name + ); + } + + if !name + .chars() + .next() + .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + || !name + .chars() + .last() + .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit()) + { + bail!( + "Invalid Open Plugins name '{}'. Names must start and end with a letter or number", + name + ); + } + + if name.contains("--") || name.contains("..") { + bail!( + "Invalid Open Plugins name '{}'. Names must not contain consecutive dashes or periods", + name + ); + } + Ok(()) } -fn find_skills(plugin_dir: &Path) -> Vec { - let skills_dir = plugin_dir.join(SKILLS_DIR); - let entries = match fs::read_dir(&skills_dir) { - Ok(e) => e, - Err(_) => return Vec::new(), - }; - - let mut skills: Vec = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if !path.is_dir() { - continue; - } - if !path.join("SKILL.md").is_file() { - continue; - } - let name = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or("unnamed") - .to_string(); - skills.push(ImportedSkill { - name, - directory: path, - }); - } - skills.sort_by(|a, b| a.name.cmp(&b.name)); - skills +fn namespaced_component_name(plugin_name: &str, component_name: &str) -> String { + format!("{plugin_name}:{component_name}") +} + +fn find_agent_skills( + plugin_dir: &Path, + skills_config: Option<&serde_json::Value>, +) -> Result> { + let mut skills = Vec::new(); + + for root in skill_root_directories(plugin_dir, skills_config)? { + collect_skill_candidates(plugin_dir, &root, &mut skills)?; + } + + if !plugin_dir.join("skills").is_dir() + && skills_config.is_none() + && plugin_dir.join("SKILL.md").is_file() + { + collect_skill_candidate(plugin_dir, plugin_dir, &mut skills)?; + } + + skills.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(skills) +} + +fn skill_root_directories( + plugin_dir: &Path, + skills_config: Option<&serde_json::Value>, +) -> Result> { + let custom_paths = skills_config + .map(parse_component_paths) + .transpose()? + .unwrap_or_default(); + + let mut roots = Vec::new(); + if !custom_paths.exclusive { + roots.push(plugin_dir.join("skills")); + } + + for path in custom_paths.paths { + roots.push(plugin_dir.join(validate_relative_plugin_path(&path)?)); + } + + Ok(dedupe_paths(roots)) +} + +#[derive(Default)] +struct ComponentPaths { + paths: Vec, + exclusive: bool, +} + +fn parse_component_paths(value: &serde_json::Value) -> Result { + match value { + serde_json::Value::String(path) => Ok(ComponentPaths { + paths: vec![path.clone()], + exclusive: false, + }), + serde_json::Value::Array(paths) => Ok(ComponentPaths { + paths: paths + .iter() + .map(|path| match path { + serde_json::Value::String(path) => Ok(path.clone()), + _ => bail!("Open Plugins component paths must be strings"), + }) + .collect::>>()?, + exclusive: false, + }), + serde_json::Value::Object(config) => { + let paths = match config.get("paths") { + Some(serde_json::Value::String(path)) => vec![path.clone()], + Some(serde_json::Value::Array(paths)) => paths + .iter() + .map(|path| match path { + serde_json::Value::String(path) => Ok(path.clone()), + _ => bail!("Open Plugins component paths must be strings"), + }) + .collect::>>()?, + Some(_) => { + bail!("Open Plugins component paths must be a string or array of strings") + } + None => Vec::new(), + }; + let exclusive = match config.get("exclusive") { + Some(serde_json::Value::Bool(exclusive)) => *exclusive, + Some(_) => bail!("Open Plugins component exclusive setting must be a boolean"), + None => false, + }; + + Ok(ComponentPaths { paths, exclusive }) + } + serde_json::Value::Null => Ok(ComponentPaths::default()), + _ => bail!("Open Plugins component paths must be a string, array, or object"), + } +} + +fn validate_relative_plugin_path(path: &str) -> Result { + if !path.starts_with("./") { + bail!( + "Open Plugins component paths must start with './': {}", + path + ); + } + + let path = Path::new(path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + bail!( + "Open Plugins component paths must stay within the plugin: {}", + path.display() + ); + } + + Ok(path.to_path_buf()) +} + +fn collect_skill_candidates( + plugin_dir: &Path, + skill_root: &Path, + skills: &mut Vec, +) -> Result<()> { + if !skill_root.is_dir() { + return Ok(()); + } + + collect_skill_candidate(plugin_dir, skill_root, skills)?; + + for entry in fs::read_dir(skill_root)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + collect_skill_candidate(plugin_dir, &path, skills)?; + } + } + + Ok(()) +} + +fn collect_skill_candidate( + plugin_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(plugin_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()) +} + +fn rewrite_skill_name(skill_file: &Path, name: &str) -> Result<()> { + let raw = fs::read_to_string(skill_file)?; + let rewritten = rewrite_skill_content_name(&raw, name)?; + fs::write(skill_file, rewritten)?; + Ok(()) +} + +fn rewrite_skill_content_name(raw: &str, name: &str) -> Result { + if !raw.trim_start().starts_with("---") { + return Ok(build_skill_md(name, raw)); + } + + let mut parts = raw.splitn(3, "---"); + let prefix = parts.next().unwrap_or_default(); + let yaml_content = parts.next().unwrap_or_default(); + let body = parts.next().unwrap_or_default(); + + if !prefix.trim().is_empty() || body.is_empty() { + return Ok(build_skill_md(name, raw)); + } + + let mut metadata = serde_yaml::from_str::(yaml_content.trim()) + .unwrap_or_else(|_| serde_yaml::Mapping::new()); + metadata.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + + Ok(format!( + "---\n{}---{}", + serde_yaml::to_string(&metadata)?, + body + )) +} + +fn build_skill_md(name: &str, body: &str) -> String { + let mut metadata = serde_yaml::Mapping::new(); + metadata.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + + format!( + "---\n{}---\n{}\n", + serde_yaml::to_string(&metadata).unwrap(), + body + ) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + paths + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn installs_open_plugins_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 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(), + &PluginInstallOptions::default(), + None, + ) + .unwrap(); + + assert_eq!(installed.name, "test-plugin"); + assert_eq!(installed.version, "1.0.0"); + assert_eq!(installed.format, PluginFormat::OpenPlugins); + assert_eq!(installed.skills.len(), 1); + assert_eq!(installed.skills[0].name, "test-plugin:audit"); + assert!(installed.directory.join(".plugin/plugin.json").is_file()); + assert!(installed + .directory + .join(crate::plugins::INSTALL_METADATA) + .is_file()); + assert_eq!(installed.directory, install_root.path().join("test-plugin")); + assert_eq!( + installed_skill_dirs(&installed.directory), + vec![installed.directory.join("skills")] + ); + assert!( + fs::read_to_string(installed.directory.join("skills/audit/SKILL.md")) + .unwrap() + .contains("name: test-plugin:audit") + ); + } + + #[test] + fn installs_open_plugins_with_root_manifest() { + 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"}"#, + ) + .unwrap(); + let skill_dir = repo.path().join("skills").join("deploy"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: deploy\ndescription: Deploy code\n---\nDo a deploy.", + ) + .unwrap(); + + let installed = install_from_manifest( + "https://example.invalid/repo.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .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!(installed.skills.is_empty()); + } + + #[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() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().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 err = install_from_manifest( + "https://example.invalid/my-plugin.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .unwrap_err(); + + assert!(err.is::()); + } + + #[test] + fn installs_custom_skill_paths() { + 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","skills":{"paths":["./custom-skills"],"exclusive":true}}"#, + ) + .unwrap(); + let skill_dir = repo.path().join("custom-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(), + &PluginInstallOptions::default(), + None, + ) + .unwrap(); + + assert_eq!(installed.skills.len(), 1); + assert_eq!(installed.skills[0].name, "test-plugin:audit"); + assert_eq!( + installed_skill_dirs(&installed.directory), + vec![installed.directory.join("custom-skills")] + ); + } + + #[test] + fn rejects_manifest_paths_that_escape_plugin() { + let err = find_agent_skills( + tempfile::tempdir().unwrap().path(), + Some(&serde_json::json!("./../outside")), + ) + .unwrap_err(); + + assert!(err.to_string().contains("must stay within the plugin")); + } + + #[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" + ); + } } diff --git a/crates/goose/src/plugins/mod.rs b/crates/goose/src/plugins/mod.rs index e474612cea..0e0343d767 100644 --- a/crates/goose/src/plugins/mod.rs +++ b/crates/goose/src/plugins/mod.rs @@ -7,6 +7,7 @@ use anyhow::{anyhow, bail, Result}; use chrono::{DateTime, Duration, Utc}; use fs_err as fs; use serde::{Deserialize, Serialize}; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::Command; use tracing::warn; @@ -93,10 +94,20 @@ pub fn installed_plugin_skill_dirs() -> Vec { Err(_) => return Vec::new(), }; + let mut seen = HashSet::new(); entries .flatten() - .map(|entry| entry.path().join("skills")) - .filter(|path| path.is_dir()) + .flat_map(|entry| { + let plugin_dir = entry.path(); + let default_skills_dir = plugin_dir.join("skills"); + let mut skill_dirs = Vec::new(); + if default_skills_dir.is_dir() { + skill_dirs.push(default_skills_dir); + } + skill_dirs.extend(formats::open_plugins::installed_skill_dirs(&plugin_dir)); + skill_dirs + }) + .filter(|path| seen.insert(path.clone())) .collect() } @@ -367,35 +378,33 @@ fn copy_dir_all(source: &Path, destination: &Path) -> Result<()> { 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::*; + #[test] + fn rejects_repo_without_supported_manifest() { + let install_root = tempfile::tempdir().unwrap(); + let repo = tempfile::tempdir().unwrap(); + + let err = install_from_checkout_at_root( + "https://example.invalid/repo.git", + repo.path(), + install_root.path(), + &PluginInstallOptions::default(), + None, + ) + .unwrap_err(); + + assert!(err.to_string().contains("No supported plugin format found")); + } + #[test] fn updates_git_backed_plugin() { let install_root = tempfile::tempdir().unwrap();