feat: support configurable way to retrieve recipes via github (#2400)

This commit is contained in:
Lifei Zhou
2025-05-06 09:54:35 +10:00
committed by GitHub
parent 3da7d2bfc8
commit 705a858cf2
8 changed files with 274 additions and 57 deletions

View File

@@ -146,19 +146,21 @@ pub enum BenchCommand {
#[derive(Subcommand)] #[derive(Subcommand)]
enum RecipeCommand { enum RecipeCommand {
/// Validate a recipe file /// Validate a recipe file
#[command(about = "Validate a recipe file")] #[command(about = "Validate a recipe")]
Validate { Validate {
/// Path to the recipe file to validate /// Recipe name to get recipe file to validate
#[arg(help = "Path to the recipe file to validate")] #[arg(help = "recipe name to get recipe file or full path to the recipe file to validate")]
file: String, recipe_name: String,
}, },
/// Generate a deeplink for a recipe file /// 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 { Deeplink {
/// Path to the recipe file /// Recipe name to get recipe file to generate deeplink
#[arg(help = "Path to the recipe file")] #[arg(
file: String, 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>, input_text: Option<String>,
/// Path to recipe.yaml file /// Recipe name or full path to the recipe file
#[arg( #[arg(
short = None, short = None,
long = "recipe", long = "recipe",
value_name = "FILE", value_name = "RECIPE_NAME or FULL_PATH_TO_RECIPE_FILE",
help = "Path to recipe.yaml file", help = "Recipe name to get recipe file or the full path of the recipe file",
long_help = "Path to a recipe.yaml file that defines a custom agent configuration", 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 = "instructions",
conflicts_with = "input_text" conflicts_with = "input_text"
)] )]
@@ -496,11 +498,12 @@ pub async fn cli() -> Result<()> {
extensions_override: None, extensions_override: None,
additional_system_prompt: None, additional_system_prompt: None,
}, },
(_, _, Some(file)) => { (_, _, Some(recipe_name)) => {
let recipe = load_recipe(&file, true, Some(params)).unwrap_or_else(|err| { let recipe =
eprintln!("{}: {}", console::style("Error").red().bold(), err); load_recipe(&recipe_name, true, Some(params)).unwrap_or_else(|err| {
std::process::exit(1); eprintln!("{}: {}", console::style("Error").red().bold(), err);
}); std::process::exit(1);
});
InputConfig { InputConfig {
contents: recipe.prompt, contents: recipe.prompt,
extensions_override: recipe.extensions, extensions_override: recipe.extensions,
@@ -568,11 +571,11 @@ pub async fn cli() -> Result<()> {
} }
Some(Command::Recipe { command }) => { Some(Command::Recipe { command }) => {
match command { match command {
RecipeCommand::Validate { file } => { RecipeCommand::Validate { recipe_name } => {
handle_validate(file)?; handle_validate(&recipe_name)?;
} }
RecipeCommand::Deeplink { file } => { RecipeCommand::Deeplink { recipe_name } => {
handle_deeplink(file)?; handle_deeplink(&recipe_name)?;
} }
} }
return Ok(()); return Ok(());

View File

@@ -21,6 +21,8 @@ use serde_json::{json, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::error::Error; 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 // useful for light themes where there is no dicernible colour contrast between
// cursor-selected and cursor-unselected items. // cursor-selected and cursor-unselected items.
const MULTISELECT_VISIBILITY_HINT: &str = "<"; const MULTISELECT_VISIBILITY_HINT: &str = "<";
@@ -193,7 +195,7 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
.item( .item(
"settings", "settings",
"Goose 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()?; .interact()?;
@@ -808,6 +810,11 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
"Toggle Experiment", "Toggle Experiment",
"Enable or disable an experiment feature", "Enable or disable an experiment feature",
) )
.item(
"recipe",
"Goose recipe github repo",
"Goose will pull recipes from this repo if not found locally.",
)
.interact()?; .interact()?;
match setting_type { match setting_type {
@@ -823,6 +830,9 @@ pub async fn configure_settings_dialog() -> Result<(), Box<dyn Error>> {
"experiment" => { "experiment" => {
toggle_experiments_dialog()?; toggle_experiments_dialog()?;
} }
"recipe" => {
configure_recipe_dialog()?;
}
_ => unreachable!(), _ => unreachable!(),
}; };
@@ -1104,3 +1114,26 @@ pub async fn configure_tool_permissions_dialog() -> Result<(), Box<dyn Error>> {
Ok(()) 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(())
}

View File

@@ -1,7 +1,6 @@
use anyhow::Result; use anyhow::Result;
use base64::Engine; use base64::Engine;
use console::style; use console::style;
use std::path::Path;
use crate::recipe::load_recipe; use crate::recipe::load_recipe;
@@ -14,9 +13,9 @@ use crate::recipe::load_recipe;
/// # Returns /// # Returns
/// ///
/// Result indicating success or failure /// 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 // Load and validate the recipe file
match load_recipe(&file_path, false, None) { match load_recipe(recipe_name, false, None) {
Ok(_) => { Ok(_) => {
println!("{} recipe file is valid", style("").green().bold()); println!("{} recipe file is valid", style("").green().bold());
Ok(()) Ok(())
@@ -37,9 +36,9 @@ pub fn handle_validate<P: AsRef<Path>>(file_path: P) -> Result<()> {
/// # Returns /// # Returns
/// ///
/// Result indicating success or failure /// 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 // Load the recipe file first to validate it
match load_recipe(&file_path, false, None) { match load_recipe(recipe_name, false, None) {
Ok(recipe) => { Ok(recipe) => {
if let Ok(recipe_json) = serde_json::to_string(&recipe) { if let Ok(recipe_json) = serde_json::to_string(&recipe) {
let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json); let deeplink = base64::engine::general_purpose::STANDARD.encode(recipe_json);

View File

@@ -4,8 +4,8 @@ pub mod cli;
pub mod commands; pub mod commands;
pub mod logging; pub mod logging;
pub mod recipe; pub mod recipe;
pub mod recipes;
pub mod session; pub mod session;
// Re-export commonly used types // Re-export commonly used types
pub use session::Session; pub use session::Session;

View File

@@ -1,8 +1,13 @@
use anyhow::{Context, Result}; use anyhow::Result;
use console::style; use console::style;
use goose::recipe::Recipe; use goose::recipe::Recipe;
use minijinja::UndefinedBehavior; 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 /// 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 file can't be read
/// - The YAML/JSON is invalid /// - The YAML/JSON is invalid
/// - The required fields are missing /// - The required fields are missing
pub fn load_recipe<P: AsRef<Path>>( pub fn load_recipe(
path: P, recipe_name: &str,
log: bool, log: bool,
params: Option<Vec<(String, String)>>, params: Option<Vec<(String, String)>>,
) -> Result<Recipe> { ) -> 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 // Check if any parameters were provided
let rendered_content = match params { let rendered_content = match params {
None => content, None => content,
Some(params) => render_content_with_params(&content, &params)?, Some(params) => render_content_with_params(&content, &params)?,
}; };
// Determine file format based on extension and parse accordingly let recipe: Recipe;
let recipe: Recipe = if let Some(extension) = path.extension() { if serde_json::from_str::<JsonValue>(&rendered_content).is_ok() {
match extension.to_str().unwrap_or("").to_lowercase().as_str() { recipe = serde_json::from_str(&rendered_content)?
"json" => serde_json::from_str(&rendered_content) } else if serde_yaml::from_str::<YamlValue>(&rendered_content).is_ok() {
.with_context(|| format!("Failed to parse JSON recipe file: {}", path.display()))?, recipe = serde_yaml::from_str(&rendered_content)?
"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()
))
}
}
} else { } else {
return Err(anyhow::anyhow!( return Err(anyhow::anyhow!(
"File has no extension: {}. Expected .yaml or .json", "Unsupported file format for recipe file. Expected .yaml or .json"
path.display()
)); ));
}; }
if log { if log {
// Display information about the loaded recipe // Display information about the loaded recipe

View 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))
}
}

View File

@@ -0,0 +1,2 @@
pub mod github_recipe;
pub mod search_recipe;

View 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(&current_dir, recipe_name) {
return Ok(content);
}
read_recipe_in_dir(&current_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
)))
}