mirror of
https://github.com/aljazceru/goose.git
synced 2025-12-23 17:14:22 +01:00
feat: support configurable way to retrieve recipes via github (#2400)
This commit is contained in:
@@ -146,19 +146,21 @@ pub enum BenchCommand {
|
||||
#[derive(Subcommand)]
|
||||
enum RecipeCommand {
|
||||
/// Validate a recipe file
|
||||
#[command(about = "Validate a recipe file")]
|
||||
#[command(about = "Validate a recipe")]
|
||||
Validate {
|
||||
/// Path to the recipe file to validate
|
||||
#[arg(help = "Path to the recipe file to validate")]
|
||||
file: String,
|
||||
/// Recipe name to get recipe file to validate
|
||||
#[arg(help = "recipe name to get recipe file or full path to the recipe file to validate")]
|
||||
recipe_name: String,
|
||||
},
|
||||
|
||||
/// Generate a deeplink for a recipe file
|
||||
#[command(about = "Generate a deeplink for a recipe file")]
|
||||
#[command(about = "Generate a deeplink for a recipe")]
|
||||
Deeplink {
|
||||
/// Path to the recipe file
|
||||
#[arg(help = "Path to the recipe file")]
|
||||
file: String,
|
||||
/// Recipe name to get recipe file to generate deeplink
|
||||
#[arg(
|
||||
help = "recipe name to get recipe file or full path to the recipe file to generate deeplink"
|
||||
)]
|
||||
recipe_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -266,13 +268,13 @@ enum Command {
|
||||
)]
|
||||
input_text: Option<String>,
|
||||
|
||||
/// Path to recipe.yaml file
|
||||
/// Recipe name or full path to the recipe file
|
||||
#[arg(
|
||||
short = None,
|
||||
long = "recipe",
|
||||
value_name = "FILE",
|
||||
help = "Path to recipe.yaml file",
|
||||
long_help = "Path to a recipe.yaml file that defines a custom agent configuration",
|
||||
value_name = "RECIPE_NAME or FULL_PATH_TO_RECIPE_FILE",
|
||||
help = "Recipe name to get recipe file or the full path of the recipe file",
|
||||
long_help = "Recipe name to get recipe file or the full path of the recipe file that defines a custom agent configuration",
|
||||
conflicts_with = "instructions",
|
||||
conflicts_with = "input_text"
|
||||
)]
|
||||
@@ -496,8 +498,9 @@ pub async fn cli() -> Result<()> {
|
||||
extensions_override: None,
|
||||
additional_system_prompt: None,
|
||||
},
|
||||
(_, _, Some(file)) => {
|
||||
let recipe = load_recipe(&file, true, Some(params)).unwrap_or_else(|err| {
|
||||
(_, _, Some(recipe_name)) => {
|
||||
let recipe =
|
||||
load_recipe(&recipe_name, true, Some(params)).unwrap_or_else(|err| {
|
||||
eprintln!("{}: {}", console::style("Error").red().bold(), err);
|
||||
std::process::exit(1);
|
||||
});
|
||||
@@ -568,11 +571,11 @@ pub async fn cli() -> Result<()> {
|
||||
}
|
||||
Some(Command::Recipe { command }) => {
|
||||
match command {
|
||||
RecipeCommand::Validate { file } => {
|
||||
handle_validate(file)?;
|
||||
RecipeCommand::Validate { recipe_name } => {
|
||||
handle_validate(&recipe_name)?;
|
||||
}
|
||||
RecipeCommand::Deeplink { file } => {
|
||||
handle_deeplink(file)?;
|
||||
RecipeCommand::Deeplink { recipe_name } => {
|
||||
handle_deeplink(&recipe_name)?;
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
|
||||
@@ -21,6 +21,8 @@ use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::error::Error;
|
||||
|
||||
use crate::recipes::github_recipe::GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY;
|
||||
|
||||
// useful for light themes where there is no dicernible colour contrast between
|
||||
// cursor-selected and cursor-unselected items.
|
||||
const MULTISELECT_VISIBILITY_HINT: &str = "<";
|
||||
@@ -193,7 +195,7 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
||||
.item(
|
||||
"settings",
|
||||
"Goose Settings",
|
||||
"Set the Goose Mode, Tool Output, Tool Permissions, Experiment and more",
|
||||
"Set the Goose Mode, Tool Output, Tool Permissions, Experiment, Goose recipe github repo and more",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
@@ -808,6 +810,11 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
|
||||
"Toggle Experiment",
|
||||
"Enable or disable an experiment feature",
|
||||
)
|
||||
.item(
|
||||
"recipe",
|
||||
"Goose recipe github repo",
|
||||
"Goose will pull recipes from this repo if not found locally.",
|
||||
)
|
||||
.interact()?;
|
||||
|
||||
match setting_type {
|
||||
@@ -823,6 +830,9 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
|
||||
"experiment" => {
|
||||
toggle_experiments_dialog()?;
|
||||
}
|
||||
"recipe" => {
|
||||
configure_recipe_dialog()?;
|
||||
}
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
@@ -1104,3 +1114,26 @@ pub async fn configure_tool_permissions_dialog() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_recipe_dialog() -> Result<(), Box<dyn Error>> {
|
||||
let key_name = GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY;
|
||||
let config = Config::global();
|
||||
let default_recipe_repo = std::env::var(key_name)
|
||||
.ok()
|
||||
.or_else(|| config.get_param(key_name).unwrap_or(None));
|
||||
let mut recipe_repo_input = cliclack::input(
|
||||
"Enter your Goose Recipe Github repo (owner/repo): eg: my_org/goose-recipes",
|
||||
)
|
||||
.required(false);
|
||||
if let Some(recipe_repo) = default_recipe_repo {
|
||||
recipe_repo_input = recipe_repo_input.default_input(&recipe_repo);
|
||||
}
|
||||
let input_value: String = recipe_repo_input.interact()?;
|
||||
// if input is blank, it clears the recipe github repo settings in the config file
|
||||
if input_value.clone().trim().is_empty() {
|
||||
config.delete(key_name)?;
|
||||
} else {
|
||||
config.set_param(key_name, Value::String(input_value))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
use console::style;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::recipe::load_recipe;
|
||||
|
||||
@@ -14,9 +13,9 @@ use crate::recipe::load_recipe;
|
||||
/// # Returns
|
||||
///
|
||||
/// Result indicating success or failure
|
||||
pub fn handle_validate<P: AsRef<Path>>(file_path: P) -> Result<()> {
|
||||
pub fn handle_validate(recipe_name: &str) -> Result<()> {
|
||||
// Load and validate the recipe file
|
||||
match load_recipe(&file_path, false, None) {
|
||||
match load_recipe(recipe_name, false, None) {
|
||||
Ok(_) => {
|
||||
println!("{} recipe file is valid", style("✓").green().bold());
|
||||
Ok(())
|
||||
@@ -37,9 +36,9 @@ pub fn handle_validate<P: AsRef<Path>>(file_path: P) -> Result<()> {
|
||||
/// # Returns
|
||||
///
|
||||
/// Result indicating success or failure
|
||||
pub fn handle_deeplink<P: AsRef<Path>>(file_path: P) -> Result<()> {
|
||||
pub fn handle_deeplink(recipe_name: &str) -> Result<()> {
|
||||
// Load the recipe file first to validate it
|
||||
match load_recipe(&file_path, false, None) {
|
||||
match load_recipe(recipe_name, false, None) {
|
||||
Ok(recipe) => {
|
||||
if let Ok(recipe_json) = serde_json::to_string(&recipe) {
|
||||
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);
|
||||
|
||||
@@ -4,8 +4,8 @@ pub mod cli;
|
||||
pub mod commands;
|
||||
pub mod logging;
|
||||
pub mod recipe;
|
||||
pub mod recipes;
|
||||
pub mod session;
|
||||
|
||||
// Re-export commonly used types
|
||||
pub use session::Session;
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
use anyhow::{Context, Result};
|
||||
use anyhow::Result;
|
||||
use console::style;
|
||||
|
||||
use goose::recipe::Recipe;
|
||||
use minijinja::UndefinedBehavior;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
use serde_json::Value as JsonValue;
|
||||
use serde_yaml::Value as YamlValue;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::recipes::search_recipe::retrieve_recipe_file;
|
||||
|
||||
/// Loads and validates a recipe from a YAML or JSON file
|
||||
///
|
||||
@@ -23,46 +28,29 @@ use std::{collections::HashMap, path::Path};
|
||||
/// - The file can't be read
|
||||
/// - The YAML/JSON is invalid
|
||||
/// - The required fields are missing
|
||||
pub fn load_recipe<P: AsRef<Path>>(
|
||||
path: P,
|
||||
pub fn load_recipe(
|
||||
recipe_name: &str,
|
||||
log: bool,
|
||||
params: Option<Vec<(String, String)>>,
|
||||
) -> Result<Recipe> {
|
||||
let path = path.as_ref();
|
||||
let content = retrieve_recipe_file(recipe_name)?;
|
||||
|
||||
// Check if file exists
|
||||
if !path.exists() {
|
||||
return Err(anyhow::anyhow!("recipe file not found: {}", path.display()));
|
||||
}
|
||||
// Read file content
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read recipe file: {}", path.display()))?;
|
||||
// Check if any parameters were provided
|
||||
let rendered_content = match params {
|
||||
None => content,
|
||||
Some(params) => render_content_with_params(&content, ¶ms)?,
|
||||
};
|
||||
|
||||
// Determine file format based on extension and parse accordingly
|
||||
let recipe: Recipe = if let Some(extension) = path.extension() {
|
||||
match extension.to_str().unwrap_or("").to_lowercase().as_str() {
|
||||
"json" => serde_json::from_str(&rendered_content)
|
||||
.with_context(|| format!("Failed to parse JSON recipe file: {}", path.display()))?,
|
||||
"yaml" => serde_yaml::from_str(&rendered_content)
|
||||
.with_context(|| format!("Failed to parse YAML recipe file: {}", path.display()))?,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Unsupported file format for recipe file: {}. Expected .yaml or .json",
|
||||
path.display()
|
||||
))
|
||||
}
|
||||
}
|
||||
let recipe: Recipe;
|
||||
if serde_json::from_str::<JsonValue>(&rendered_content).is_ok() {
|
||||
recipe = serde_json::from_str(&rendered_content)?
|
||||
} else if serde_yaml::from_str::<YamlValue>(&rendered_content).is_ok() {
|
||||
recipe = serde_yaml::from_str(&rendered_content)?
|
||||
} else {
|
||||
return Err(anyhow::anyhow!(
|
||||
"File has no extension: {}. Expected .yaml or .json",
|
||||
path.display()
|
||||
"Unsupported file format for recipe file. Expected .yaml or .json"
|
||||
));
|
||||
};
|
||||
}
|
||||
|
||||
if log {
|
||||
// Display information about the loaded recipe
|
||||
|
||||
131
crates/goose-cli/src/recipes/github_recipe.rs
Normal file
131
crates/goose-cli/src/recipes/github_recipe.rs
Normal file
@@ -0,0 +1,131 @@
|
||||
use anyhow::Result;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub const GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY: &str = "GOOSE_RECIPE_GITHUB_REPO";
|
||||
pub fn retrieve_recipe_from_github(
|
||||
recipe_name: &str,
|
||||
recipe_repo_full_name: &str,
|
||||
) -> Result<String> {
|
||||
println!(
|
||||
"retrieving recipe from github repo {}",
|
||||
recipe_repo_full_name
|
||||
);
|
||||
ensure_gh_authenticated()?;
|
||||
let local_repo_path = ensure_repo_cloned(recipe_repo_full_name)?;
|
||||
fetch_origin(&local_repo_path)?;
|
||||
let file_extensions = ["yaml", "json"];
|
||||
|
||||
for ext in file_extensions {
|
||||
let file_path_in_repo = format!("{}/recipe.{}", recipe_name, ext);
|
||||
match get_file_content_from_github(&local_repo_path, &file_path_in_repo) {
|
||||
Ok(content) => {
|
||||
println!(
|
||||
"retrieved recipe from github repo {}/{}",
|
||||
recipe_repo_full_name, file_path_in_repo
|
||||
);
|
||||
return Ok(content);
|
||||
}
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(anyhow::anyhow!(
|
||||
"Failed to retrieve recipe.yaml or recipe.json in path {} in github repo {} ",
|
||||
recipe_name,
|
||||
recipe_repo_full_name,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_file_content_from_github(
|
||||
local_repo_path: &Path,
|
||||
file_path_in_repo: &str,
|
||||
) -> Result<String> {
|
||||
let ref_and_path = format!("origin/main:{}", file_path_in_repo);
|
||||
let error_message: String = format!(
|
||||
"Failed to get content from {} in github repo",
|
||||
file_path_in_repo
|
||||
);
|
||||
let output = Command::new("git")
|
||||
.args(["show", &ref_and_path])
|
||||
.current_dir(local_repo_path)
|
||||
.output()
|
||||
.map_err(|_: std::io::Error| anyhow::anyhow!(error_message.clone()))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(error_message.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_gh_authenticated() -> Result<()> {
|
||||
// Check authentication status
|
||||
let status = Command::new("gh")
|
||||
.args(["auth", "status"])
|
||||
.status()
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!("Failed to run `gh auth status`. Make sure you have `gh` installed.")
|
||||
})?;
|
||||
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
println!("GitHub CLI is not authenticated. Launching `gh auth login`...");
|
||||
// Run `gh auth login` interactively
|
||||
let login_status = Command::new("gh")
|
||||
.args(["auth", "login"])
|
||||
.status()
|
||||
.map_err(|_| anyhow::anyhow!("Failed to run `gh auth login`"))?;
|
||||
|
||||
if !login_status.success() {
|
||||
Err(anyhow::anyhow!("Failed to authenticate using GitHub CLI."))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_repo_cloned(recipe_repo_full_name: &str) -> Result<PathBuf> {
|
||||
let local_repo_parent_path = env::temp_dir();
|
||||
let (_, repo_name) = recipe_repo_full_name
|
||||
.split_once('/')
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid repository name format"))?;
|
||||
|
||||
let local_repo_path = local_repo_parent_path.clone().join(repo_name);
|
||||
if local_repo_path.join(".git").exists() {
|
||||
Ok(local_repo_path)
|
||||
} else {
|
||||
// Create the local repo parent directory if it doesn't exist
|
||||
if !local_repo_parent_path.exists() {
|
||||
std::fs::create_dir_all(local_repo_parent_path.clone())?;
|
||||
}
|
||||
let error_message: String = format!("Failed to clone repo: {}", recipe_repo_full_name);
|
||||
let status = Command::new("gh")
|
||||
.args(["repo", "clone", recipe_repo_full_name])
|
||||
.current_dir(local_repo_parent_path.clone())
|
||||
.status()
|
||||
.map_err(|_: std::io::Error| anyhow::anyhow!(error_message.clone()))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(local_repo_path)
|
||||
} else {
|
||||
Err(anyhow::anyhow!(error_message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch_origin(local_repo_path: &Path) -> Result<()> {
|
||||
let error_message: String = format!("Failed to fetch at {}", local_repo_path.to_str().unwrap());
|
||||
let status = Command::new("git")
|
||||
.args(["fetch", "origin"])
|
||||
.current_dir(local_repo_path)
|
||||
.status()
|
||||
.map_err(|_| anyhow::anyhow!(error_message.clone()))?;
|
||||
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow::anyhow!(error_message))
|
||||
}
|
||||
}
|
||||
2
crates/goose-cli/src/recipes/mod.rs
Normal file
2
crates/goose-cli/src/recipes/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
pub mod github_recipe;
|
||||
pub mod search_recipe;
|
||||
61
crates/goose-cli/src/recipes/search_recipe.rs
Normal file
61
crates/goose-cli/src/recipes/search_recipe.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use goose::config::Config;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY};
|
||||
|
||||
pub fn retrieve_recipe_file(recipe_name: &str) -> Result<String> {
|
||||
// If recipe_name ends with yaml or json, treat it as a direct path
|
||||
if recipe_name.ends_with(".yaml") || recipe_name.ends_with(".json") {
|
||||
let path = PathBuf::from(recipe_name);
|
||||
return read_recipe_file(path);
|
||||
}
|
||||
|
||||
// First check current directory
|
||||
let current_dir = std::env::current_dir()?;
|
||||
if let Ok(content) = read_recipe_in_dir(¤t_dir, recipe_name) {
|
||||
return Ok(content);
|
||||
}
|
||||
read_recipe_in_dir(¤t_dir, recipe_name).or_else(|e| {
|
||||
if let Some(recipe_repo_full_name) = configured_github_recipe_repo() {
|
||||
retrieve_recipe_from_github(recipe_name, &recipe_repo_full_name)
|
||||
} else {
|
||||
Err(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn configured_github_recipe_repo() -> Option<String> {
|
||||
let config = Config::global();
|
||||
match config.get_param(GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY) {
|
||||
Ok(Some(recipe_repo_full_name)) => Some(recipe_repo_full_name),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn read_recipe_file<P: AsRef<Path>>(recipe_path: P) -> Result<String> {
|
||||
let path = recipe_path.as_ref();
|
||||
|
||||
if path.exists() {
|
||||
let content = fs::read_to_string(path)
|
||||
.with_context(|| format!("Failed to read recipe file: {}", path.display()))?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Err(anyhow!("Recipe file not found: {}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<String> {
|
||||
for ext in &["yaml", "json"] {
|
||||
let recipe_path = dir.join(format!("{}.{}", recipe_name, ext));
|
||||
match read_recipe_file(recipe_path) {
|
||||
Ok(content) => return Ok(content),
|
||||
Err(_) => continue,
|
||||
}
|
||||
}
|
||||
Err(anyhow!(format!(
|
||||
"No {}.yaml or {}.json recipe file found in current directory.",
|
||||
recipe_name, recipe_name
|
||||
)))
|
||||
}
|
||||
Reference in New Issue
Block a user