From c6991f39b7ccefa41fc12a94d8d0e9b6fa6433a8 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 1 Jul 2025 11:09:58 +1000 Subject: [PATCH] feat: additional sub recipes via command line (#3163) --- Cargo.lock | 1 + crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/cli.rs | 55 +++--- .../goose-cli/src/recipes/extract_from_cli.rs | 161 ++++++++++++++++++ crates/goose-cli/src/recipes/mod.rs | 1 + 5 files changed, 187 insertions(+), 32 deletions(-) create mode 100644 crates/goose-cli/src/recipes/extract_from_cli.rs diff --git a/Cargo.lock b/Cargo.lock index dfbe2a59..34ebec37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3512,6 +3512,7 @@ dependencies = [ "clap 4.5.31", "cliclack", "console", + "dirs 5.0.1", "etcetera", "futures", "goose", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index da00f89e..be6b72fc 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -59,6 +59,7 @@ regex = "1.11.1" minijinja = { version = "2.10.2", features = ["loader"] } nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" +dirs = "5.0" # Web server dependencies axum = { version = "0.8.1", features = ["ws", "macros"] } tower-http = { version = "0.5", features = ["cors", "fs"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3cfc7180..f7fe62c2 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -17,9 +17,8 @@ use crate::commands::schedule::{ }; use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::logging::setup_logging; -use crate::recipes::recipe::{ - explain_recipe_with_parameters, load_recipe_as_template, load_recipe_content_as_template, -}; +use crate::recipes::extract_from_cli::extract_recipe_info_from_cli; +use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_content_as_template}; use crate::session; use crate::session::{build_session, SessionBuilderConfig, SessionSettings}; use goose_bench::bench_config::BenchRunConfig; @@ -519,6 +518,16 @@ enum Command { hide = true )] scheduled_job_id: Option, + + /// 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.", + action = clap::ArgAction::Append + )] + additional_sub_recipes: Vec, }, /// Recipe utilities for validation and deeplinking @@ -593,10 +602,10 @@ enum CliProviderVariant { } #[derive(Debug)] -struct InputConfig { - contents: Option, - extensions_override: Option>, - additional_system_prompt: Option, +pub struct InputConfig { + pub contents: Option, + pub extensions_override: Option>, + pub additional_system_prompt: Option, } pub async fn cli() -> Result<()> { @@ -725,15 +734,14 @@ pub async fn cli() -> Result<()> { render_recipe, scheduled_job_id, quiet, + additional_sub_recipes, }) => { let (input_config, session_settings, sub_recipes) = match ( instructions, input_text, recipe, - explain, - render_recipe, ) { - (Some(file), _, _, _, _) if file == "-" => { + (Some(file), _, _) if file == "-" => { let mut input = String::new(); std::io::stdin() .read_to_string(&mut input) @@ -749,7 +757,7 @@ pub async fn cli() -> Result<()> { 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{}", @@ -767,7 +775,7 @@ pub async fn cli() -> Result<()> { None, ) } - (_, Some(text), _, _, _) => ( + (_, Some(text), _) => ( InputConfig { contents: Some(text), extensions_override: None, @@ -776,7 +784,7 @@ pub async fn cli() -> Result<()> { None, None, ), - (_, _, Some(recipe_name), explain, render_recipe) => { + (_, _, Some(recipe_name)) => { if explain { explain_recipe_with_parameters(&recipe_name, params)?; return Ok(()); @@ -790,26 +798,9 @@ pub async fn cli() -> Result<()> { println!("{}", recipe); return Ok(()); } - let recipe = - load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| { - eprintln!("{}: {}", console::style("Error").red().bold(), err); - std::process::exit(1); - }); - ( - InputConfig { - contents: recipe.prompt, - extensions_override: recipe.extensions, - additional_system_prompt: recipe.instructions, - }, - recipe.settings.map(|s| SessionSettings { - goose_provider: s.goose_provider, - goose_model: s.goose_model, - temperature: s.temperature, - }), - recipe.sub_recipes, - ) + extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes)? } - (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/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs new file mode 100644 index 00000000..eb853f99 --- /dev/null +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -0,0 +1,161 @@ +use std::path::PathBuf; + +use anyhow::Result; +use goose::recipe::SubRecipe; + +use crate::{cli::InputConfig, recipes::recipe::load_recipe_as_template, session::SessionSettings}; + +pub fn extract_recipe_info_from_cli( + recipe_name: String, + params: Vec<(String, String)>, + additional_sub_recipes: Vec, +) -> Result<(InputConfig, Option, Option>)> { + let recipe = load_recipe_as_template(&recipe_name, params).unwrap_or_else(|err| { + eprintln!("{}: {}", console::style("Error").red().bold(), err); + std::process::exit(1); + }); + 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); + }); + } + Ok(( + InputConfig { + contents: recipe.prompt, + extensions_override: recipe.extensions, + additional_system_prompt: recipe.instructions, + }, + recipe.settings.map(|s| SessionSettings { + goose_provider: s.goose_provider, + goose_model: s.goose_model, + temperature: s.temperature, + }), + Some(all_sub_recipes), + )) +} + +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); + } + } + PathBuf::from(path) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_extract_recipe_info_from_cli_basic() { + let (_temp_dir, recipe_path) = create_recipe(); + let params = vec![("name".to_string(), "my_value".to_string())]; + let recipe_name = recipe_path.to_str().unwrap().to_string(); + + let (input_config, settings, sub_recipes) = + extract_recipe_info_from_cli(recipe_name, params, Vec::new()).unwrap(); + + assert_eq!(input_config.contents, Some("test_prompt".to_string())); + assert_eq!( + input_config.additional_system_prompt, + Some("test_instructions my_value".to_string()) + ); + assert!(input_config.extensions_override.is_none()); + + assert!(settings.is_some()); + let settings = settings.unwrap(); + assert_eq!(settings.goose_provider, Some("test_provider".to_string())); + assert_eq!(settings.goose_model, Some("test_model".to_string())); + assert_eq!(settings.temperature, Some(0.7)); + + assert!(sub_recipes.is_some()); + let sub_recipes = sub_recipes.unwrap(); + assert!(sub_recipes.len() == 1); + 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()); + } + + #[test] + fn test_extract_recipe_info_from_cli_with_additional_sub_recipes() { + let (_temp_dir, recipe_path) = create_recipe(); + 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(), + ]; + + let (input_config, settings, sub_recipes) = + extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes).unwrap(); + + assert_eq!(input_config.contents, Some("test_prompt".to_string())); + assert_eq!( + input_config.additional_system_prompt, + Some("test_instructions my_value".to_string()) + ); + assert!(input_config.extensions_override.is_none()); + + assert!(settings.is_some()); + let settings = settings.unwrap(); + assert_eq!(settings.goose_provider, Some("test_provider".to_string())); + assert_eq!(settings.goose_model, Some("test_model".to_string())); + assert_eq!(settings.temperature, Some(0.7)); + + assert!(sub_recipes.is_some()); + let sub_recipes = sub_recipes.unwrap(); + assert!(sub_recipes.len() == 3); + 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].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].name, "sub_recipe2".to_string()); + assert!(sub_recipes[2].values.is_none()); + } + + fn create_recipe() -> (TempDir, PathBuf) { + let test_recipe_content = r#" +title: test_recipe +description: A test recipe +instructions: test_instructions {{name}} +prompt: test_prompt +parameters: +- key: name + description: name + input_type: string + requirement: required +settings: + goose_provider: test_provider + goose_model: test_model + temperature: 0.7 +sub_recipes: +- path: existing_sub_recipe.yaml + name: existing_sub_recipe +"#; + let temp_dir = tempfile::tempdir().unwrap(); + let recipe_path: std::path::PathBuf = temp_dir.path().join("test_recipe.yaml"); + + std::fs::write(&recipe_path, test_recipe_content).unwrap(); + (temp_dir, recipe_path) + } +} diff --git a/crates/goose-cli/src/recipes/mod.rs b/crates/goose-cli/src/recipes/mod.rs index 4d63c789..a1ceef61 100644 --- a/crates/goose-cli/src/recipes/mod.rs +++ b/crates/goose-cli/src/recipes/mod.rs @@ -1,3 +1,4 @@ +pub mod extract_from_cli; pub mod github_recipe; pub mod print_recipe; pub mod recipe;