install plugins (#8949)
This commit is contained in:
@@ -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<Command>) -> &'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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PluginInstall> {
|
||||
install_from_manifest(source, checkout_dir, &plugin_install_dir())
|
||||
}
|
||||
|
||||
fn install_from_manifest(
|
||||
source: &str,
|
||||
checkout_dir: &Path,
|
||||
install_root: &Path,
|
||||
) -> Result<PluginInstall> {
|
||||
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<Vec<SkillCandidate>> {
|
||||
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<SkillCandidate>,
|
||||
) -> 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<String> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub(super) mod gemini;
|
||||
@@ -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<ImportedSkill>,
|
||||
}
|
||||
|
||||
#[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<PathBuf> {
|
||||
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<PluginInstall> {
|
||||
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<PluginInstall> {
|
||||
match formats::gemini::try_install_from_manifest(source, checkout_dir) {
|
||||
Ok(install) => Ok(install),
|
||||
Err(err) if err.is::<FormatNotSupported>() => {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user