diff --git a/Cargo.lock b/Cargo.lock index d260db02d5..5e01656e8d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2484,6 +2484,7 @@ dependencies = [ "mcp-client", "mcp-core", "mcp-server", + "minijinja", "once_cell", "rand", "regex", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 698d0d156f..cf9adb517d 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -52,6 +52,7 @@ shlex = "1.3.0" async-trait = "0.1.86" base64 = "0.22.1" regex = "1.11.1" +minijinja = "2.8.0" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index b0109bd0d9..69fd2b1695 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -59,6 +59,13 @@ fn extract_identifier(identifier: Identifier) -> session::Identifier { } } +fn parse_key_val(s: &str) -> Result<(String, String), String> { + match s.split_once('=') { + Some((key, value)) => Ok((key.to_string(), value.to_string())), + None => Err(format!("invalid KEY=VALUE: {}", s)), + } +} + #[derive(Subcommand)] enum SessionCommand { #[command(about = "List all available sessions")] @@ -271,6 +278,16 @@ enum Command { )] recipe: Option, + #[arg( + long, + value_name = "KEY=VALUE", + help = "Dynamic parameters (e.g., --params username=alice --params channel_name=goose-channel)", + long_help = "Key-value parameters to pass to the recipe file. Can be specified multiple times.", + action = clap::ArgAction::Append, + value_parser = parse_key_val, + )] + params: Vec<(String, String)>, + /// Continue in interactive mode after processing input #[arg( short = 's', @@ -414,7 +431,7 @@ pub async fn cli() -> Result<()> { } None => { // Run session command by default - let mut session = build_session(SessionBuilderConfig { + let mut session: crate::Session = build_session(SessionBuilderConfig { identifier: identifier.map(extract_identifier), resume, extensions, @@ -445,6 +462,7 @@ pub async fn cli() -> Result<()> { extensions, remote_extensions, builtins, + params, }) => { let input_config = match (instructions, input_text, recipe) { (Some(file), _, _) if file == "-" => { @@ -479,7 +497,7 @@ pub async fn cli() -> Result<()> { additional_system_prompt: None, }, (_, _, Some(file)) => { - let recipe = load_recipe(&file, true).unwrap_or_else(|err| { + let recipe = load_recipe(&file, true, Some(params)).unwrap_or_else(|err| { eprintln!("{}: {}", console::style("Error").red().bold(), err); std::process::exit(1); }); diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 388133ef68..2b8cdbc63f 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -16,7 +16,7 @@ use crate::recipe::load_recipe; /// Result indicating success or failure pub fn handle_validate>(file_path: P) -> Result<()> { // Load and validate the recipe file - match load_recipe(&file_path, false) { + match load_recipe(&file_path, false, None) { Ok(_) => { println!("{} recipe file is valid", style("✓").green().bold()); Ok(()) @@ -39,7 +39,7 @@ pub fn handle_validate>(file_path: P) -> Result<()> { /// Result indicating success or failure pub fn handle_deeplink>(file_path: P) -> Result<()> { // Load the recipe file first to validate it - match load_recipe(&file_path, false) { + match load_recipe(&file_path, 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/recipe.rs b/crates/goose-cli/src/recipe.rs index 5bebddacd9..7e57c20c24 100644 --- a/crates/goose-cli/src/recipe.rs +++ b/crates/goose-cli/src/recipe.rs @@ -1,8 +1,8 @@ use anyhow::{Context, Result}; use console::style; -use std::path::Path; - use goose::recipe::Recipe; +use minijinja::UndefinedBehavior; +use std::{collections::HashMap, path::Path}; /// Loads and validates a recipe from a YAML or JSON file /// @@ -10,6 +10,7 @@ use goose::recipe::Recipe; /// /// * `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 /// @@ -22,7 +23,11 @@ use goose::recipe::Recipe; /// - The file can't be read /// - The YAML/JSON is invalid /// - The required fields are missing -pub fn load_recipe>(path: P, log: bool) -> Result { +pub fn load_recipe>( + path: P, + log: bool, + params: Option>, +) -> Result { let path = path.as_ref(); // Check if file exists @@ -32,13 +37,18 @@ pub fn load_recipe>(path: P, log: bool) -> Result { // 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(&content) + "json" => serde_json::from_str(&rendered_content) .with_context(|| format!("Failed to parse JSON recipe file: {}", path.display()))?, - "yaml" => serde_yaml::from_str(&content) + "yaml" => serde_yaml::from_str(&rendered_content) .with_context(|| format!("Failed to parse YAML recipe file: {}", path.display()))?, _ => { return Err(anyhow::anyhow!( @@ -68,3 +78,60 @@ pub fn load_recipe>(path: P, log: bool) -> Result { Ok(recipe) } + +fn render_content_with_params(content: &str, params: &[(String, String)]) -> Result { + // Turn params into HashMap + let param_map: HashMap = params.iter().cloned().collect(); + + // Create a minijinja environment and context + let mut env = minijinja::Environment::new(); + env.set_undefined_behavior(UndefinedBehavior::Strict); + let template = env.template_from_str(content) + .map_err(|_| anyhow::anyhow!("Failed to render recipe, please check if the recipe has proper syntax for variables: eg: {{ variable_name }}"))?; + + // Render the template with the parameters + template.render(param_map).map_err(|_| { + anyhow::anyhow!( + "Failed to render the recipe - please check if all required parameters are provided" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_render_content_with_params() { + // Test basic parameter substitution + let content = "Hello {{ name }}!"; + let params = vec![("name".to_string(), "World".to_string())]; + let result = render_content_with_params(content, ¶ms).unwrap(); + assert_eq!(result, "Hello World!"); + + // Test multiple parameters + let content = "{{ greeting }} {{ name }}!"; + let params = vec![ + ("greeting".to_string(), "Hi".to_string()), + ("name".to_string(), "Alice".to_string()), + ]; + let result = render_content_with_params(content, ¶ms).unwrap(); + assert_eq!(result, "Hi Alice!"); + + // Test missing parameter results in error + let content = "Hello {{ missing }}!"; + let params = vec![]; + let err = render_content_with_params(content, ¶ms).unwrap_err(); + assert!(err + .to_string() + .contains("please check if all required parameters")); + + // Test invalid template syntax results in error + let content = "Hello {{ unclosed"; + let params = vec![]; + let err = render_content_with_params(content, ¶ms).unwrap_err(); + assert!(err + .to_string() + .contains("please check if the recipe has proper syntax")); + } +}