diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index c4b0798d..5e3a95a9 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -522,9 +522,9 @@ enum Command { /// Additional sub-recipe file paths #[arg( long = "sub-recipe", - value_name = "FILE", - help = "Path to a sub-recipe YAML file (can be specified multiple times)", - long_help = "Specify paths to sub-recipe YAML files that contain additional recipe configuration or instructions to be used alongside the main recipe. Can be specified multiple times to include multiple sub-recipes.", + value_name = "RECIPE", + help = "Sub-recipe name or file path (can be specified multiple times)", + long_help = "Specify sub-recipes to include alongside the main recipe. Can be:\n - Recipe names from GitHub (if GOOSE_RECIPE_GITHUB_REPO is configured)\n - Local file paths to YAML files\nCan be specified multiple times to include multiple sub-recipes.", action = clap::ArgAction::Append )] additional_sub_recipes: Vec, diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index e84fd564..0b886a15 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -1,8 +1,9 @@ use std::path::PathBuf; -use anyhow::Result; +use anyhow::{anyhow, Result}; use goose::recipe::{Response, SubRecipe}; +use crate::recipes::search_recipe::retrieve_recipe_file; use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings}; #[allow(clippy::type_complexity)] @@ -22,20 +23,27 @@ pub fn extract_recipe_info_from_cli( }); let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default(); if !additional_sub_recipes.is_empty() { - additional_sub_recipes.iter().for_each(|sub_recipe_path| { - let path = convert_path(sub_recipe_path); - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - let additional_sub_recipe: SubRecipe = SubRecipe { - path: path.to_string_lossy().to_string(), - name, - values: None, - }; - all_sub_recipes.push(additional_sub_recipe); - }); + for sub_recipe_name in additional_sub_recipes { + match retrieve_recipe_file(&sub_recipe_name) { + Ok(recipe_file) => { + let name = extract_recipe_name(&sub_recipe_name); + let recipe_file_path = recipe_file.file_path; + let additional_sub_recipe = SubRecipe { + path: recipe_file_path.to_string_lossy().to_string(), + name, + values: None, + }; + all_sub_recipes.push(additional_sub_recipe); + } + Err(e) => { + return Err(anyhow!( + "Could not retrieve sub-recipe '{}': {}", + sub_recipe_name, + e + )); + } + } + } } Ok(( InputConfig { @@ -53,13 +61,18 @@ pub fn extract_recipe_info_from_cli( )) } -fn convert_path(path: &str) -> PathBuf { - if let Some(stripped) = path.strip_prefix("~/") { - if let Some(home_dir) = dirs::home_dir() { - return home_dir.join(stripped); - } +fn extract_recipe_name(recipe_identifier: &str) -> String { + // If it's a path (contains / or \), extract the file stem + if recipe_identifier.contains('/') || recipe_identifier.contains('\\') { + PathBuf::from(recipe_identifier) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string() + } else { + // If it's just a name (like "weekly-updates"), use it directly + recipe_identifier.to_string() } - PathBuf::from(path) } #[cfg(test)] @@ -114,11 +127,22 @@ mod tests { #[test] fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() { let (_temp_dir, recipe_path) = create_recipe(); + + // Create actual sub-recipe files in the temp directory + std::fs::create_dir_all(_temp_dir.path().join("path/to")).unwrap(); + std::fs::create_dir_all(_temp_dir.path().join("another")).unwrap(); + + let sub_recipe1_path = _temp_dir.path().join("path/to/sub_recipe1.yaml"); + let sub_recipe2_path = _temp_dir.path().join("another/sub_recipe2.yaml"); + + std::fs::write(&sub_recipe1_path, "title: Sub Recipe 1").unwrap(); + std::fs::write(&sub_recipe2_path, "title: Sub Recipe 2").unwrap(); + let params = vec![("name".to_string(), "my_value".to_string())]; let recipe_name = recipe_path.to_str().unwrap().to_string(); let additional_sub_recipes = vec![ - "path/to/sub_recipe1.yaml".to_string(), - "another/sub_recipe2.yaml".to_string(), + sub_recipe1_path.to_string_lossy().to_string(), + sub_recipe2_path.to_string_lossy().to_string(), ]; let (input_config, settings, sub_recipes, response) = @@ -143,10 +167,16 @@ mod tests { assert_eq!(sub_recipes[0].path, "existing_sub_recipe.yaml".to_string()); assert_eq!(sub_recipes[0].name, "existing_sub_recipe".to_string()); assert!(sub_recipes[0].values.is_none()); - assert_eq!(sub_recipes[1].path, "path/to/sub_recipe1.yaml".to_string()); + assert_eq!( + sub_recipes[1].path, + sub_recipe1_path.to_string_lossy().to_string() + ); assert_eq!(sub_recipes[1].name, "sub_recipe1".to_string()); assert!(sub_recipes[1].values.is_none()); - assert_eq!(sub_recipes[2].path, "another/sub_recipe2.yaml".to_string()); + assert_eq!( + sub_recipes[2].path, + sub_recipe2_path.to_string_lossy().to_string() + ); assert_eq!(sub_recipes[2].name, "sub_recipe2".to_string()); assert!(sub_recipes[2].values.is_none()); assert!(response.is_some()); diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs index 962da236..2763d24f 100644 --- a/crates/goose-cli/src/recipes/github_recipe.rs +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -9,12 +9,13 @@ use std::process::Stdio; use tar::Archive; use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; +use crate::recipes::search_recipe::RecipeFile; 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, PathBuf)> { +) -> Result { println!( "📦 Looking for recipe \"{}\" in github repo: {}", recipe_name, recipe_repo_full_name @@ -26,7 +27,13 @@ pub fn retrieve_recipe_from_github( for attempt in 1..=max_attempts { match clone_and_download_recipe(recipe_name, recipe_repo_full_name) { Ok(download_dir) => match read_recipe_file(&download_dir) { - Ok(content) => return Ok((content, download_dir)), + Ok((content, recipe_file_local_path)) => { + return Ok(RecipeFile { + content, + parent_dir: download_dir.clone(), + file_path: recipe_file_local_path, + }) + } Err(err) => return Err(err), }, Err(err) => { @@ -47,7 +54,7 @@ fn clean_cloned_dirs(recipe_repo_full_name: &str) -> anyhow::Result<()> { } Ok(()) } -fn read_recipe_file(download_dir: &Path) -> Result { +fn read_recipe_file(download_dir: &Path) -> Result<(String, PathBuf)> { for ext in RECIPE_FILE_EXTENSIONS { let candidate_file_path = download_dir.join(format!("recipe.{}", ext)); if candidate_file_path.exists() { @@ -59,7 +66,7 @@ fn read_recipe_file(download_dir: &Path) -> Result { .unwrap() .display() ); - return Ok(content); + return Ok((content, candidate_file_path)); } } diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index c151f037..5593130b 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -2,7 +2,7 @@ use crate::recipes::print_recipe::{ missing_parameters_command_line, print_parameters_with_values, print_recipe_explanation, print_required_parameters_for_template, }; -use crate::recipes::search_recipe::retrieve_recipe_file; +use crate::recipes::search_recipe::{retrieve_recipe_file, RecipeFile}; use crate::recipes::template_recipe::{ parse_recipe_content, render_recipe_content_with_params, render_recipe_for_preview, }; @@ -18,7 +18,11 @@ pub fn load_recipe_content_as_template( recipe_name: &str, params: Vec<(String, String)>, ) -> Result { - let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; + let RecipeFile { + content: recipe_file_content, + parent_dir: recipe_parent_dir, + .. + } = retrieve_recipe_file(recipe_name)?; let recipe_dir_str = recipe_parent_dir .to_str() .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; @@ -70,7 +74,11 @@ pub fn load_recipe_as_template(recipe_name: &str, params: Vec<(String, String)>) } pub fn load_recipe(recipe_name: &str) -> Result { - let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; + let RecipeFile { + content: recipe_file_content, + parent_dir: recipe_parent_dir, + .. + } = retrieve_recipe_file(recipe_name)?; let recipe_dir_str = recipe_parent_dir .to_str() .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; @@ -87,7 +95,11 @@ pub fn explain_recipe_with_parameters( recipe_name: &str, params: Vec<(String, String)>, ) -> Result<()> { - let (recipe_file_content, recipe_parent_dir) = retrieve_recipe_file(recipe_name)?; + let RecipeFile { + content: recipe_file_content, + parent_dir: recipe_parent_dir, + .. + } = retrieve_recipe_file(recipe_name)?; let recipe_dir_str = recipe_parent_dir .to_str() .ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?; diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs index 398e5a01..d87c30e9 100644 --- a/crates/goose-cli/src/recipes/search_recipe.rs +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -9,8 +9,13 @@ use super::github_recipe::{retrieve_recipe_from_github, GOOSE_RECIPE_GITHUB_REPO const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; -pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> { - // If recipe_name ends with yaml or json, treat it as a direct file path +pub struct RecipeFile { + pub content: String, + pub parent_dir: PathBuf, + pub file_path: PathBuf, +} + +pub fn retrieve_recipe_file(recipe_name: &str) -> Result { if RECIPE_FILE_EXTENSIONS .iter() .any(|ext| recipe_name.ends_with(&format!(".{}", ext))) @@ -18,6 +23,12 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> { let path = PathBuf::from(recipe_name); return read_recipe_file(path); } + if is_file_path(recipe_name) || is_file_name(recipe_name) { + return Err(anyhow!( + "Recipe file {} is not a json or yaml file", + recipe_name + )); + } retrieve_recipe_from_local_path(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) @@ -27,7 +38,18 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result<(String, PathBuf)> { }) } -fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf)> { +fn is_file_path(recipe_name: &str) -> bool { + recipe_name.contains('/') + || recipe_name.contains('\\') + || recipe_name.starts_with('~') + || recipe_name.starts_with('.') +} + +fn is_file_name(recipe_name: &str) -> bool { + Path::new(recipe_name).extension().is_some() +} + +fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result { for ext in RECIPE_FILE_EXTENSIONS { let recipe_path = dir.join(format!("{}.{}", recipe_name, ext)); if let Ok(result) = read_recipe_file(recipe_path) { @@ -42,7 +64,7 @@ fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result<(String, PathBuf) ))) } -fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result<(String, PathBuf)> { +fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result { let mut search_dirs = vec![PathBuf::from(".")]; if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) { let path_separator = if cfg!(windows) { ';' } else { ':' }; @@ -78,10 +100,22 @@ fn configured_github_recipe_repo() -> Option { } } -fn read_recipe_file>(recipe_path: P) -> Result<(String, PathBuf)> { - let path = recipe_path.as_ref(); +fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf { + if let Some(path_str) = path.to_str() { + if let Some(stripped) = path_str.strip_prefix("~/") { + if let Some(home_dir) = dirs::home_dir() { + return home_dir.join(stripped); + } + } + } + PathBuf::from(path) +} - let content = fs::read_to_string(path) +fn read_recipe_file>(recipe_path: P) -> Result { + let raw_path = recipe_path.as_ref(); + let path = convert_path_with_tilde_expansion(raw_path); + + let content = fs::read_to_string(&path) .map_err(|e| anyhow!("Failed to read recipe file {}: {}", path.display(), e))?; let canonical = path.canonicalize().map_err(|e| { @@ -97,5 +131,9 @@ fn read_recipe_file>(recipe_path: P) -> Result<(String, PathBuf)> .ok_or_else(|| anyhow!("Resolved path has no parent: {}", canonical.display()))? .to_path_buf(); - Ok((content, parent_dir)) + Ok(RecipeFile { + content, + parent_dir, + file_path: canonical, + }) }