From e968e0022cfbda6d87e11ba81d5d8462e804d37a Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Thu, 15 May 2025 10:57:05 +1000 Subject: [PATCH] feat: show recipe explanation (#2530) Co-authored-by: Michael Neale --- crates/goose-cli/src/cli.rs | 28 ++- crates/goose-cli/src/commands/recipe.rs | 4 +- crates/goose-cli/src/recipes/mod.rs | 1 + crates/goose-cli/src/recipes/print_recipe.rs | 72 +++++++ crates/goose-cli/src/recipes/recipe.rs | 199 ++++++++++--------- crates/goose/src/recipe/mod.rs | 23 +++ 6 files changed, 227 insertions(+), 100 deletions(-) create mode 100644 crates/goose-cli/src/recipes/print_recipe.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index ec54873d..369628fa 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -11,7 +11,7 @@ use crate::commands::project::{handle_project_default, handle_projects_interacti use crate::commands::recipe::{handle_deeplink, handle_validate}; use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::logging::setup_logging; -use crate::recipes::recipe::load_recipe; +use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_as_template}; use crate::session; use crate::session::{build_session, SessionBuilderConfig}; use goose_bench::bench_config::BenchRunConfig; @@ -333,6 +333,13 @@ enum Command { )] no_session: bool, + /// Show the recipe title, description, and parameters + #[arg( + long = "explain", + help = "Show the recipe title, description, and parameters" + )] + explain: bool, + /// Maximum number of consecutive identical tool calls allowed #[arg( long = "max-tool-repetitions", @@ -536,9 +543,10 @@ pub async fn cli() -> Result<()> { remote_extensions, builtins, params, + explain, }) => { - let input_config = match (instructions, input_text, recipe) { - (Some(file), _, _) if file == "-" => { + let input_config = match (instructions, input_text, recipe, explain) { + (Some(file), _, _, _) if file == "-" => { let mut input = String::new(); std::io::stdin() .read_to_string(&mut input) @@ -550,7 +558,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: None, } } - (Some(file), _, _) => { + (Some(file), _, _, _) => { let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| { eprintln!( "Instruction file not found — did you mean to use goose run --text?\n{}", @@ -564,14 +572,18 @@ pub async fn cli() -> Result<()> { additional_system_prompt: None, } } - (_, Some(text), _) => InputConfig { + (_, Some(text), _, _) => InputConfig { contents: Some(text), extensions_override: None, additional_system_prompt: None, }, - (_, _, Some(recipe_name)) => { + (_, _, Some(recipe_name), explain) => { + if explain { + explain_recipe_with_parameters(&recipe_name, params)?; + return Ok(()); + } let recipe = - load_recipe(&recipe_name, true, Some(params)).unwrap_or_else(|err| { + load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| { eprintln!("{}: {}", console::style("Error").red().bold(), err); std::process::exit(1); }); @@ -581,7 +593,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: recipe.instructions, } } - (None, None, None) => { + (None, None, None, _) => { eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin."); std::process::exit(1); } diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 57736b76..9abc036b 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -15,7 +15,7 @@ use crate::recipes::recipe::load_recipe; /// Result indicating success or failure pub fn handle_validate(recipe_name: &str) -> Result<()> { // Load and validate the recipe file - match load_recipe(recipe_name, false, None) { + match load_recipe(recipe_name) { Ok(_) => { println!("{} recipe file is valid", style("✓").green().bold()); Ok(()) @@ -38,7 +38,7 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { /// Result indicating success or failure pub fn handle_deeplink(recipe_name: &str) -> Result<()> { // Load the recipe file first to validate it - match load_recipe(recipe_name, false, None) { + match load_recipe(recipe_name) { 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/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index 99056549..9d1ac702 100644 --- a/crates/goose-cli/src/recipes/mod.rs +++ b/crates/goose-cli/src/recipes/mod.rs @@ -1,3 +1,4 @@ pub mod github_recipe; +pub mod print_recipe; pub mod recipe; pub mod search_recipe; diff --git a/crates/goose-cli/src/recipes/print_recipe.rs b/crates/goose-cli/src/recipes/print_recipe.rs new file mode 100644 index 00000000..5cef0b8e --- /dev/null +++ b/crates/goose-cli/src/recipes/print_recipe.rs @@ -0,0 +1,72 @@ +use std::collections::HashMap; + +use console::style; +use goose::recipe::Recipe; + +pub fn print_recipe_explanation(recipe: &Recipe) { + println!( + "{} {}", + style("🔍 Loading recipe:").bold().green(), + style(&recipe.title).green() + ); + println!("{}", style("📄 Description:").bold()); + println!(" {}", recipe.description); + if let Some(params) = &recipe.parameters { + if !params.is_empty() { + println!("{}", style("⚙️ Recipe Parameters:").bold()); + for param in params { + let default_display = match ¶m.default { + Some(val) => format!(" (default: {})", val), + None => String::new(), + }; + + println!( + " - {} ({}, {}){}: {}", + style(¶m.key).cyan(), + param.input_type, + param.requirement, + default_display, + param.description + ); + } + } + } +} + +pub fn print_required_parameters_for_template( + params_for_template: HashMap, + missing_params: Vec, +) { + if !params_for_template.is_empty() { + println!( + "{}", + style("📥 Parameters used to load this recipe:").bold() + ); + for (key, value) in params_for_template { + println!(" {}: {}", key, value); + } + } + if !missing_params.is_empty() { + println!( + "{}", + style("🔴 Missing parameters in the command line if you want to run the recipe:") + .bold() + ); + for param in missing_params.iter() { + println!(" - {}", param); + } + println!( + "📩 {}:", + style("Please provide the following parameters in the command line if you want to run the recipe:").bold() + ); + println!(" {}", missing_parameters_command_line(missing_params)); + } +} + +pub fn missing_parameters_command_line(missing_params: Vec) -> String { + missing_params + .iter() + .map(|key| format!("--params {}=your_value", key)) + .collect::>() + .join(" ") +} diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index e18b347e..539538fe 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -1,6 +1,10 @@ use anyhow::Result; use console::style; +use crate::recipes::print_recipe::{ + missing_parameters_command_line, print_recipe_explanation, + print_required_parameters_for_template, +}; use crate::recipes::search_recipe::retrieve_recipe_file; use goose::recipe::{Recipe, RecipeParameter, RecipeParameterRequirement}; use minijinja::{Environment, Error, Template, UndefinedBehavior}; @@ -8,12 +12,63 @@ use serde_json::Value as JsonValue; use serde_yaml::Value as YamlValue; use std::collections::{HashMap, HashSet}; +/// Loads, validates a recipe from a YAML or JSON file, and renders it with the given parameters +/// +/// # Arguments +/// +/// * `path` - Path to the recipe file (YAML or JSON) +/// * `params` - parameters to render the recipe with +/// +/// # Returns +/// +/// The rendered recipe if successful +/// +/// # Errors +/// +/// Returns an error if: +/// - Recipe is not valid +/// - The required fields are missing +pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) -> Result { + let recipe_file_content = retrieve_recipe_file(recipe_name)?; + + let recipe = validate_recipe_file_parameters(&recipe_file_content)?; + + let (params_for_template, missing_params) = + apply_values_to_parameters(¶ms, recipe.parameters, true)?; + if !missing_params.is_empty() { + return Err(anyhow::anyhow!( + "Please provide the following parameters in the command line: {}", + missing_parameters_command_line(missing_params) + )); + } + + let rendered_content = render_content_with_params(&recipe_file_content, ¶ms_for_template)?; + + let recipe = parse_recipe_content(&rendered_content)?; + + // Display information about the loaded recipe + println!( + "{} {}", + style("Loading recipe:").green().bold(), + style(&recipe.title).green() + ); + println!("{} {}", style("Description:").bold(), &recipe.description); + + if !params_for_template.is_empty() { + println!("{}", style("Parameters used to load this recipe:").bold()); + for (key, value) in params_for_template { + println!("{}: {}", key, value); + } + } + println!(); + Ok(recipe) +} + /// Loads and validates a recipe from a YAML or JSON file /// /// # Arguments /// /// * `path` - Path to the recipe file (YAML or JSON) -/// * `log` - whether to log information about the recipe or not /// * `params` - optional parameters to render the recipe with /// /// # Returns @@ -26,65 +81,43 @@ use std::collections::{HashMap, HashSet}; /// - The file doesn't exist /// - The file can't be read /// - The YAML/JSON is invalid -/// - The required fields are missing -pub fn load_recipe( - recipe_name: &str, - log: bool, - params: Option>, -) -> Result { +/// - The parameter definition does not match the template variables in the recipe file +pub fn load_recipe(recipe_name: &str) -> Result { let recipe_file_content = retrieve_recipe_file(recipe_name)?; - let recipe_parameters = validate_recipe_file_parameters(&recipe_file_content)?; - - let (rendered_content, params_for_template) = if let Some(user_params) = params { - let params_for_template = apply_values_to_parameters(&user_params, recipe_parameters)?; - ( - render_content_with_params(&recipe_file_content, ¶ms_for_template)?, - Some(params_for_template), - ) - } else { - (recipe_file_content, None) - }; - - let recipe = parse_recipe_content(&rendered_content)?; - if log { - // Display information about the loaded recipe - println!( - "{} {}", - style("Loading recipe:").green().bold(), - style(&recipe.title).green() - ); - println!("{} {}", style("Description:").dim(), &recipe.description); - - if let Some(params) = params_for_template { - if !params.is_empty() { - println!("{}", style("Parameters:").dim()); - for (key, value) in params { - println!("{}: {}", key, value); - } - } - } - - println!(); // Add a blank line for spacing - } - - Ok(recipe) + validate_recipe_file_parameters(&recipe_file_content) } -fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result> { +pub fn explain_recipe_with_parameters( + recipe_name: &str, + params: Vec<(String, String)>, +) -> Result<()> { + let recipe_file_content = retrieve_recipe_file(recipe_name)?; + + let raw_recipe = validate_recipe_file_parameters(&recipe_file_content)?; + print_recipe_explanation(&raw_recipe); + let recipe_parameters = raw_recipe.parameters; + let (params_for_template, missing_params) = + apply_values_to_parameters(¶ms, recipe_parameters, false)?; + print_required_parameters_for_template(params_for_template, missing_params); + + Ok(()) +} + +fn validate_recipe_file_parameters(recipe_file_content: &str) -> Result { let recipe_from_recipe_file: Recipe = parse_recipe_content(recipe_file_content)?; validate_optional_parameters(&recipe_from_recipe_file)?; - validate_parameters_in_template(recipe_from_recipe_file, recipe_file_content) + validate_parameters_in_template(&recipe_from_recipe_file.parameters, recipe_file_content)?; + Ok(recipe_from_recipe_file) } fn validate_parameters_in_template( - recipe: Recipe, + recipe_parameters: &Option>, recipe_file_content: &str, -) -> Result> { +) -> Result<()> { let template_variables = extract_template_variables(recipe_file_content)?; - let param_keys: HashSet = recipe - .parameters + let param_keys: HashSet = recipe_parameters .as_ref() .unwrap_or(&vec![]) .iter() @@ -100,7 +133,7 @@ fn validate_parameters_in_template( .collect::>(); if missing_keys.is_empty() && extra_keys.is_empty() { - return Ok(recipe.parameters.unwrap_or_default()); + return Ok(()); } let mut message = String::new(); @@ -173,15 +206,16 @@ fn extract_template_variables(template_str: &str) -> Result> { fn apply_values_to_parameters( user_params: &[(String, String)], - recipe_parameters: Vec, -) -> Result> { + recipe_parameters: Option>, + enable_user_prompt: bool, +) -> Result<(HashMap, Vec)> { let mut param_map: HashMap = user_params.iter().cloned().collect(); let mut missing_params: Vec = Vec::new(); - for param in recipe_parameters { + for param in recipe_parameters.unwrap_or_default() { if !param_map.contains_key(¶m.key) { match (¶m.default, ¶m.requirement) { (Some(default), _) => param_map.insert(param.key.clone(), default.clone()), - (None, RecipeParameterRequirement::UserPrompt) => { + (None, RecipeParameterRequirement::UserPrompt) if enable_user_prompt => { let input_value = cliclack::input(format!( "Please enter {} ({})", param.key, param.description @@ -196,29 +230,16 @@ fn apply_values_to_parameters( }; } } - match missing_params.is_empty() { - true => Ok(param_map), - false => { - let formatted = missing_params - .iter() - .map(|key| format!("--params {}=your_value", key)) - .collect::>() - .join(" "); - - Err(anyhow::anyhow!( - "Please provide the following parameters in the command line: {}", - formatted - )) - } - } + Ok((param_map, missing_params)) } fn render_content_with_params(content: &str, params: &HashMap) -> Result { // Create a minijinja environment and context let mut env = minijinja::Environment::new(); env.set_undefined_behavior(UndefinedBehavior::Strict); - let template: Template<'_, '_> = env.template_from_str(content) - .map_err(|e: Error| anyhow::anyhow!("Failed to render recipe {}, please check if the recipe has proper syntax for variables: eg: {{ variable_name }}", e.to_string()))?; + let template: Template<'_, '_> = env + .template_from_str(content) + .map_err(|e: Error| anyhow::anyhow!("Invalid template syntax: {}", e.to_string()))?; // Render the template with the parameters template.render(params).map_err(|e: Error| { @@ -284,13 +305,11 @@ mod tests { let content = "Hello {{ unclosed"; let params = HashMap::new(); let err = render_content_with_params(content, ¶ms).unwrap_err(); - assert!(err - .to_string() - .contains("please check if the recipe has proper syntax")); + assert!(err.to_string().contains("Invalid template syntax")); } #[test] - fn test_load_recipe_success() { + fn test_load_recipe_as_template_success() { let instructions_and_parameters = r#" "instructions": "Test instructions with {{ my_name }}", "parameters": [ @@ -305,7 +324,7 @@ mod tests { let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe(recipe_path.to_str().unwrap(), false, Some(params)).unwrap(); + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -323,7 +342,7 @@ mod tests { } #[test] - fn test_load_recipe_success_variable_in_prompt() { + fn test_load_recipe_as_template_success_variable_in_prompt() { let instructions_and_parameters = r#" "instructions": "Test instructions", "prompt": "My prompt {{ my_name }}", @@ -339,7 +358,7 @@ mod tests { let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); let params = vec![("my_name".to_string(), "value".to_string())]; - let recipe = load_recipe(recipe_path.to_str().unwrap(), false, Some(params)).unwrap(); + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -356,7 +375,7 @@ mod tests { } #[test] - fn test_load_recipe_wrong_parameters_in_recipe_file() { + fn test_load_recipe_as_template_wrong_parameters_in_recipe_file() { let instructions_and_parameters = r#" "instructions": "Test instructions with {{ expected_param1 }} {{ expected_param2 }}", "parameters": [ @@ -369,7 +388,7 @@ mod tests { ]"#; let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None); + let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); assert!(load_recipe_result.is_err()); let err = load_recipe_result.unwrap_err(); println!("{}", err.to_string()); @@ -384,7 +403,7 @@ mod tests { } #[test] - fn test_load_recipe_with_default_values_in_recipe_file() { + fn test_load_recipe_as_template_with_default_values_in_recipe_file() { let instructions_and_parameters = r#" "instructions": "Test instructions with {{ param_with_default }} {{ param_without_default }}", "parameters": [ @@ -405,7 +424,7 @@ mod tests { let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); let params = vec![("param_without_default".to_string(), "value1".to_string())]; - let recipe = load_recipe(recipe_path.to_str().unwrap(), false, Some(params)).unwrap(); + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), params).unwrap(); assert_eq!(recipe.title, "Test Recipe"); assert_eq!(recipe.description, "A test recipe"); @@ -416,7 +435,7 @@ mod tests { } #[test] - fn test_load_recipe_optional_parameters_without_default_values_in_recipe_file() { + fn test_load_recipe_as_template_optional_parameters_without_default_values_in_recipe_file() { let instructions_and_parameters = r#" "instructions": "Test instructions with {{ optional_param }}", "parameters": [ @@ -429,7 +448,7 @@ mod tests { ]"#; let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None); + let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()); assert!(load_recipe_result.is_err()); let err = load_recipe_result.unwrap_err(); println!("{}", err.to_string()); @@ -439,7 +458,7 @@ mod tests { } #[test] - fn test_load_recipe_wrong_input_type_in_recipe_file() { + fn test_load_recipe_as_template_wrong_input_type_in_recipe_file() { let instructions_and_parameters = r#" "instructions": "Test instructions with {{ param }}", "parameters": [ @@ -453,22 +472,22 @@ mod tests { let params = vec![("param".to_string(), "value".to_string())]; let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, Some(params)); + let load_recipe_result = load_recipe_as_template(recipe_path.to_str().unwrap(), params); assert!(load_recipe_result.is_err()); let err = load_recipe_result.unwrap_err(); - assert!(err.to_string().contains("unknown variant `some_invalid_type`, expected one of `string`, `number`, `date`, `file`")); + assert!(err + .to_string() + .contains("unknown variant `some_invalid_type`")); } #[test] - fn test_load_recipe_success_without_parameters() { + fn test_load_recipe_as_template_success_without_parameters() { let instructions_and_parameters = r#" "instructions": "Test instructions" "#; let (_temp_dir, recipe_path) = setup_recipe_file(instructions_and_parameters); - let load_recipe_result = load_recipe(recipe_path.to_str().unwrap(), false, None); - assert!(load_recipe_result.is_ok()); - let recipe = load_recipe_result.unwrap(); + let recipe = load_recipe_as_template(recipe_path.to_str().unwrap(), Vec::new()).unwrap(); assert_eq!(recipe.instructions.unwrap(), "Test instructions"); assert!(recipe.parameters.is_none()); } diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 3eb97880..8891dbd4 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -1,3 +1,5 @@ +use std::fmt; + use crate::agents::extension::ExtensionConfig; use serde::{Deserialize, Serialize}; @@ -102,15 +104,36 @@ pub enum RecipeParameterRequirement { UserPrompt, } +impl fmt::Display for RecipeParameterRequirement { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + serde_json::to_string(self).unwrap().trim_matches('"') + ) + } +} + #[derive(Serialize, Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum RecipeParameterInputType { String, Number, + Boolean, Date, File, } +impl fmt::Display for RecipeParameterInputType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "{}", + serde_json::to_string(self).unwrap().trim_matches('"') + ) + } +} + #[derive(Serialize, Deserialize, Debug)] pub struct RecipeParameter { pub key: String,