feat: additional sub recipes via command line (#3163)

This commit is contained in:
Lifei Zhou
2025-07-01 11:09:58 +10:00
committed by GitHub
parent 0dd8edfc3a
commit c6991f39b7
5 changed files with 187 additions and 32 deletions

1
Cargo.lock generated
View File

@@ -3512,6 +3512,7 @@ dependencies = [
"clap 4.5.31", "clap 4.5.31",
"cliclack", "cliclack",
"console", "console",
"dirs 5.0.1",
"etcetera", "etcetera",
"futures", "futures",
"goose", "goose",

View File

@@ -59,6 +59,7 @@ regex = "1.11.1"
minijinja = { version = "2.10.2", features = ["loader"] } minijinja = { version = "2.10.2", features = ["loader"] }
nix = { version = "0.30.1", features = ["process", "signal"] } nix = { version = "0.30.1", features = ["process", "signal"] }
tar = "0.4" tar = "0.4"
dirs = "5.0"
# Web server dependencies # Web server dependencies
axum = { version = "0.8.1", features = ["ws", "macros"] } axum = { version = "0.8.1", features = ["ws", "macros"] }
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }

View File

@@ -17,9 +17,8 @@ use crate::commands::schedule::{
}; };
use crate::commands::session::{handle_session_list, handle_session_remove}; use crate::commands::session::{handle_session_list, handle_session_remove};
use crate::logging::setup_logging; use crate::logging::setup_logging;
use crate::recipes::recipe::{ use crate::recipes::extract_from_cli::extract_recipe_info_from_cli;
explain_recipe_with_parameters, load_recipe_as_template, load_recipe_content_as_template, use crate::recipes::recipe::{explain_recipe_with_parameters, load_recipe_content_as_template};
};
use crate::session; use crate::session;
use crate::session::{build_session, SessionBuilderConfig, SessionSettings}; use crate::session::{build_session, SessionBuilderConfig, SessionSettings};
use goose_bench::bench_config::BenchRunConfig; use goose_bench::bench_config::BenchRunConfig;
@@ -519,6 +518,16 @@ enum Command {
hide = true hide = true
)] )]
scheduled_job_id: Option<String>, scheduled_job_id: Option<String>,
/// 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<String>,
}, },
/// Recipe utilities for validation and deeplinking /// Recipe utilities for validation and deeplinking
@@ -593,10 +602,10 @@ enum CliProviderVariant {
} }
#[derive(Debug)] #[derive(Debug)]
struct InputConfig { pub struct InputConfig {
contents: Option<String>, pub contents: Option<String>,
extensions_override: Option<Vec<ExtensionConfig>>, pub extensions_override: Option<Vec<ExtensionConfig>>,
additional_system_prompt: Option<String>, pub additional_system_prompt: Option<String>,
} }
pub async fn cli() -> Result<()> { pub async fn cli() -> Result<()> {
@@ -725,15 +734,14 @@ pub async fn cli() -> Result<()> {
render_recipe, render_recipe,
scheduled_job_id, scheduled_job_id,
quiet, quiet,
additional_sub_recipes,
}) => { }) => {
let (input_config, session_settings, sub_recipes) = match ( let (input_config, session_settings, sub_recipes) = match (
instructions, instructions,
input_text, input_text,
recipe, recipe,
explain,
render_recipe,
) { ) {
(Some(file), _, _, _, _) if file == "-" => { (Some(file), _, _) if file == "-" => {
let mut input = String::new(); let mut input = String::new();
std::io::stdin() std::io::stdin()
.read_to_string(&mut input) .read_to_string(&mut input)
@@ -749,7 +757,7 @@ pub async fn cli() -> Result<()> {
None, None,
) )
} }
(Some(file), _, _, _, _) => { (Some(file), _, _) => {
let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| { let contents = std::fs::read_to_string(&file).unwrap_or_else(|err| {
eprintln!( eprintln!(
"Instruction file not found — did you mean to use goose run --text?\n{}", "Instruction file not found — did you mean to use goose run --text?\n{}",
@@ -767,7 +775,7 @@ pub async fn cli() -> Result<()> {
None, None,
) )
} }
(_, Some(text), _, _, _) => ( (_, Some(text), _) => (
InputConfig { InputConfig {
contents: Some(text), contents: Some(text),
extensions_override: None, extensions_override: None,
@@ -776,7 +784,7 @@ pub async fn cli() -> Result<()> {
None, None,
None, None,
), ),
(_, _, Some(recipe_name), explain, render_recipe) => { (_, _, Some(recipe_name)) => {
if explain { if explain {
explain_recipe_with_parameters(&recipe_name, params)?; explain_recipe_with_parameters(&recipe_name, params)?;
return Ok(()); return Ok(());
@@ -790,26 +798,9 @@ pub async fn cli() -> Result<()> {
println!("{}", recipe); println!("{}", recipe);
return Ok(()); return Ok(());
} }
let recipe = extract_recipe_info_from_cli(recipe_name, params, additional_sub_recipes)?
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,
)
} }
(None, None, None, _, _) => { (None, None, None) => {
eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin."); eprintln!("Error: Must provide either --instructions (-i), --text (-t), or --recipe. Use -i - for stdin.");
std::process::exit(1); std::process::exit(1);
} }

View File

@@ -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<String>,
) -> Result<(InputConfig, Option<SessionSettings>, Option<Vec<SubRecipe>>)> {
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)
}
}

View File

@@ -1,3 +1,4 @@
pub mod extract_from_cli;
pub mod github_recipe; pub mod github_recipe;
pub mod print_recipe; pub mod print_recipe;
pub mod recipe; pub mod recipe;