diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 90e5b463b272..83ebb8644a92 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -58,6 +58,7 @@ tokio-util = { version = "0.7.15", features = ["compat"] } is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" +open = "5.3.2" [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 5c644bf6eb1b..0b1e7e4b49b5 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -8,7 +8,7 @@ use crate::commands::bench::agent_generator; use crate::commands::configure::handle_configure; use crate::commands::info::handle_info; use crate::commands::project::{handle_project_default, handle_projects_interactive}; -use crate::commands::recipe::{handle_deeplink, handle_list, handle_validate}; +use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate}; // Import the new handlers from commands::schedule use crate::commands::schedule::{ handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove, @@ -283,6 +283,14 @@ enum RecipeCommand { recipe_name: String, }, + /// Open a recipe in Goose Desktop + #[command(about = "Open a recipe in Goose Desktop")] + Open { + /// Recipe name to get recipe file to open + #[arg(help = "recipe name or full path to the recipe file")] + recipe_name: String, + }, + /// List available recipes #[command(about = "List available recipes")] List { @@ -1215,6 +1223,9 @@ pub async fn cli() -> Result<()> { RecipeCommand::Deeplink { recipe_name } => { handle_deeplink(&recipe_name)?; } + RecipeCommand::Open { recipe_name } => { + handle_open(&recipe_name)?; + } RecipeCommand::List { format, verbose } => { handle_list(&format, verbose)?; } diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index 0e71dcae31c6..a6fd629d7542 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -33,36 +33,75 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { /// /// # Arguments /// -/// * `file_path` - Path to the recipe file +/// * `recipe_name` - Path to the recipe file /// /// # Returns /// /// Result indicating success or failure pub fn handle_deeplink(recipe_name: &str) -> Result { - // Load the recipe file first to validate it - match load_recipe_for_validation(recipe_name) { - Ok(recipe) => match recipe_deeplink::encode(&recipe) { - Ok(encoded) => { - println!( - "{} Generated deeplink for: {}", - style("✓").green().bold(), - recipe.title - ); - let full_url = format!("goose://recipe?config={}", encoded); - println!("{}", full_url); - Ok(full_url) - } - Err(err) => { - println!( - "{} Failed to encode recipe: {}", - style("✗").red().bold(), - err - ); - Err(anyhow::anyhow!("Failed to encode recipe: {}", err)) + match generate_deeplink(recipe_name) { + Ok((deeplink_url, recipe)) => { + println!( + "{} Generated deeplink for: {}", + style("✓").green().bold(), + recipe.title + ); + println!("{}", deeplink_url); + Ok(deeplink_url) + } + Err(err) => { + println!( + "{} Failed to encode recipe: {}", + style("✗").red().bold(), + err + ); + Err(err) + } + } +} + +/// Opens a recipe in Goose Desktop +/// +/// # Arguments +/// +/// * `recipe_name` - Path to the recipe file +/// +/// # Returns +/// +/// Result indicating success or failure +pub fn handle_open(recipe_name: &str) -> Result<()> { + // Generate the deeplink using the helper function (no printing) + // This reuses all the validation and encoding logic + match generate_deeplink(recipe_name) { + Ok((deeplink_url, recipe)) => { + // Attempt to open the deeplink + match open::that(&deeplink_url) { + Ok(_) => { + println!( + "{} Opened recipe '{}' in Goose Desktop", + style("✓").green().bold(), + recipe.title + ); + Ok(()) + } + Err(err) => { + println!( + "{} Failed to open recipe in Goose Desktop: {}", + style("✗").red().bold(), + err + ); + println!("Generated deeplink: {}", deeplink_url); + println!("You can manually copy and open the URL above, or ensure Goose Desktop is installed."); + Err(anyhow::anyhow!("Failed to open recipe: {}", err)) + } } - }, + } Err(err) => { - println!("{} {}", style("✗").red().bold(), err); + println!( + "{} Failed to encode recipe: {}", + style("✗").red().bold(), + err + ); Err(err) } } @@ -129,6 +168,27 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> { Ok(()) } +/// Helper function to generate a deeplink +/// +/// # Arguments +/// +/// * `recipe_name` - Path to the recipe file +/// +/// # Returns +/// +/// Result containing the deeplink URL and recipe +fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> { + // Load the recipe file first to validate it + let recipe = load_recipe_for_validation(recipe_name)?; + match recipe_deeplink::encode(&recipe) { + Ok(encoded) => { + let full_url = format!("goose://recipe?config={}", encoded); + Ok((full_url, recipe)) + } + Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)), + } +} + #[cfg(test)] mod tests { use super::*; @@ -204,6 +264,28 @@ response: assert!(result.is_err()); } + #[test] + fn test_handle_open_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + // Test handle_open - should attempt to open but may fail (that's expected in test environment) + // We just want to ensure it doesn't panic and handles the error gracefully + let result = handle_open(&recipe_path); + // The result may be Ok or Err depending on whether the system can open the URL + // In a test environment, it will likely fail to open, but that's fine + // We're mainly testing that the function doesn't panic and processes the recipe correctly + match result { + Ok(_) => { + // Successfully opened (unlikely in test environment) + } + Err(_) => { + // Failed to open (expected in test environment) - this is fine + } + } + } + #[test] fn test_handle_validation_valid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -239,4 +321,30 @@ response: .to_string() .contains("JSON schema validation failed")); } + + #[test] + fn test_generate_deeplink_valid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let result = generate_deeplink(&recipe_path); + assert!(result.is_ok()); + let (url, recipe) = result.unwrap(); + assert!(url.starts_with("goose://recipe?config=")); + assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema"); + assert_eq!(recipe.description, "A test recipe with valid JSON schema"); + let encoded_part = url.strip_prefix("goose://recipe?config=").unwrap(); + assert!(!encoded_part.is_empty()); + } + + #[test] + fn test_generate_deeplink_invalid_recipe() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); + + let result = generate_deeplink(&recipe_path); + assert!(result.is_err()); + } }