Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/goose-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
13 changes: 12 additions & 1 deletion crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)?;
}
Expand Down
154 changes: 131 additions & 23 deletions crates/goose-cli/src/commands/recipe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
// 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)
}
}
Expand Down Expand Up @@ -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::*;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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());
}
}
Loading