diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 73106340..18cdb694 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -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, - /// 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,11 +498,12 @@ 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| { - eprintln!("{}: {}", console::style("Error").red().bold(), err); - std::process::exit(1); - }); + (_, _, 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); + }); InputConfig { contents: recipe.prompt, extensions_override: recipe.extensions, @@ -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(()); diff --git a/crates/goose-cli/src/commands/configure.rs b/crates/goose-cli/src/commands/configure.rs index ec6ca71d..51be3742 100644 --- a/crates/goose-cli/src/commands/configure.rs +++ b/crates/goose-cli/src/commands/configure.rs @@ -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> { .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> { "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> { "experiment" => { toggle_experiments_dialog()?; } + "recipe" => { + configure_recipe_dialog()?; + } _ => unreachable!(), }; @@ -1104,3 +1114,26 @@ pub async fn configure_tool_permissions_dialog() -> Result<(), Box> { Ok(()) } + +fn configure_recipe_dialog() -> Result<(), Box> { + 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(()) +} diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 2b8cdbc6..f2bdf52e 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -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>(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>(file_path: P) -> Result<()> { /// # Returns /// /// Result indicating success or failure -pub fn handle_deeplink>(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); diff --git a/crates/goose-cli/src/lib.rs b/crates/goose-cli/src/lib.rs index 1eb18f2e..3be13935 100644 --- a/crates/goose-cli/src/lib.rs +++ b/crates/goose-cli/src/lib.rs @@ -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; diff --git a/crates/goose-cli/src/recipe.rs b/crates/goose-cli/src/recipe.rs index 7e57c20c..44a1462e 100644 --- a/crates/goose-cli/src/recipe.rs +++ b/crates/goose-cli/src/recipe.rs @@ -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>( - path: P, +pub fn load_recipe( + recipe_name: &str, log: bool, params: Option>, ) -> Result { - 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::(&rendered_content).is_ok() { + recipe = serde_json::from_str(&rendered_content)? + } else if serde_yaml::from_str::(&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 diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs new file mode 100644 index 00000000..785978ea --- /dev/null +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -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 { + 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 { + 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 { + 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)) + } +} diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs new file mode 100644 index 00000000..dba435f1 --- /dev/null +++ b/crates/goose-cli/src/recipes/mod.rs @@ -0,0 +1,2 @@ +pub mod github_recipe; +pub mod search_recipe; diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs new file mode 100644 index 00000000..5d85fed5 --- /dev/null +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -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 { + // 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 { + 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>(recipe_path: P) -> Result { + 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 { + 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 + ))) +}