diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5c644bf6eb1b..cc83fe0271fa 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1006,7 +1006,7 @@ pub async fn cli() -> Result<()> { .unwrap_or(&recipe_name); let recipe_version = - crate::recipes::search_recipe::retrieve_recipe_file(&recipe_name) + crate::recipes::search_recipe::load_recipe_file(&recipe_name) .ok() .and_then(|rf| { goose::recipe::template_recipe::parse_recipe_content( diff --git a/crates/goose-cli/src/recipes/extract_from_cli.rs b/crates/goose-cli/src/recipes/extract_from_cli.rs index b3012550ed62..e5af27864242 100644 --- a/crates/goose-cli/src/recipes/extract_from_cli.rs +++ b/crates/goose-cli/src/recipes/extract_from_cli.rs @@ -5,7 +5,7 @@ use goose::recipe::SubRecipe; use crate::recipes::print_recipe::print_recipe_info; use crate::recipes::recipe::load_recipe; -use crate::recipes::search_recipe::retrieve_recipe_file; +use crate::recipes::search_recipe::load_recipe_file; use crate::{ cli::{InputConfig, RecipeInfo}, session::SessionSettings, @@ -24,7 +24,7 @@ pub fn extract_recipe_info_from_cli( let mut all_sub_recipes = recipe.sub_recipes.clone().unwrap_or_default(); if !additional_sub_recipes.is_empty() { for sub_recipe_name in additional_sub_recipes { - match retrieve_recipe_file(&sub_recipe_name) { + match load_recipe_file(&sub_recipe_name) { Ok(recipe_file) => { let name = extract_recipe_name(&sub_recipe_name); let recipe_file_path = recipe_file.file_path; diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs index e7f92855845e..c7f88b823fe4 100644 --- a/crates/goose-cli/src/recipes/github_recipe.rs +++ b/crates/goose-cli/src/recipes/github_recipe.rs @@ -1,9 +1,9 @@ use anyhow::{anyhow, Result}; use console::style; use goose::recipe::template_recipe::parse_recipe_content; +use goose::recipe::RECIPE_FILE_EXTENSIONS; use serde::{Deserialize, Serialize}; -use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; use goose::recipe::read_recipe_file_content::RecipeFile; use std::env; use std::fs; diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs index 9c4204f917b8..32b05f551561 100644 --- a/crates/goose-cli/src/recipes/recipe.rs +++ b/crates/goose-cli/src/recipes/recipe.rs @@ -2,7 +2,7 @@ use crate::recipes::print_recipe::{ missing_parameters_command_line, print_recipe_explanation, print_required_parameters_for_template, }; -use crate::recipes::search_recipe::retrieve_recipe_file; +use crate::recipes::search_recipe::load_recipe_file; use crate::recipes::secret_discovery::{discover_recipe_secrets, SecretRequirement}; use anyhow::Result; use goose::config::Config; @@ -15,8 +15,6 @@ use goose::recipe::Recipe; use serde_json::Value; use std::collections::HashMap; -pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; - fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result { |key: &str, description: &str| -> Result { let input_value = @@ -26,7 +24,7 @@ fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result { } fn load_recipe_file_with_dir(recipe_name: &str) -> Result<(RecipeFile, String)> { - let recipe_file = retrieve_recipe_file(recipe_name)?; + let recipe_file = load_recipe_file(recipe_name)?; let recipe_dir_str = recipe_file .parent_dir .to_str() @@ -36,7 +34,7 @@ fn load_recipe_file_with_dir(recipe_name: &str) -> Result<(RecipeFile, String)> } pub fn load_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result { - let recipe_file = retrieve_recipe_file(recipe_name)?; + let recipe_file = load_recipe_file(recipe_name)?; match build_recipe_from_template(recipe_file, params, Some(create_user_prompt_callback())) { Ok(recipe) => { let secret_requirements = discover_recipe_secrets(&recipe); diff --git a/crates/goose-cli/src/recipes/search_recipe.rs b/crates/goose-cli/src/recipes/search_recipe.rs index 8854e0dc4824..5bf509099062 100644 --- a/crates/goose-cli/src/recipes/search_recipe.rs +++ b/crates/goose-cli/src/recipes/search_recipe.rs @@ -1,35 +1,15 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; use goose::config::Config; -use goose::recipe::read_recipe_file_content::{read_recipe_file, RecipeFile}; -use goose::recipe::template_recipe::parse_recipe_content; -use std::env; -use std::fs; -use std::path::{Path, PathBuf}; - -use crate::recipes::recipe::RECIPE_FILE_EXTENSIONS; +use goose::recipe::read_recipe_file_content::RecipeFile; use super::github_recipe::{ list_github_recipes, retrieve_recipe_from_github, RecipeInfo, RecipeSource, GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY, }; +use goose::recipe::local_recipes::{list_local_recipes, load_local_recipe_file}; -const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; - -pub fn retrieve_recipe_file(recipe_name: &str) -> Result { - if RECIPE_FILE_EXTENSIONS - .iter() - .any(|ext| recipe_name.ends_with(&format!(".{}", ext))) - { - let path = PathBuf::from(recipe_name); - return read_recipe_file(path); - } - if is_file_path(recipe_name) || is_file_name(recipe_name) { - return Err(anyhow!( - "Recipe file {} is not a json or yaml file", - recipe_name - )); - } - retrieve_recipe_from_local_path(recipe_name).or_else(|e| { +pub fn load_recipe_file(recipe_name: &str) -> Result { + load_local_recipe_file(recipe_name).or_else(|e| { if let Some(recipe_repo_full_name) = configured_github_recipe_repo() { retrieve_recipe_from_github(recipe_name, &recipe_repo_full_name) } else { @@ -38,60 +18,6 @@ pub fn retrieve_recipe_file(recipe_name: &str) -> Result { }) } -fn is_file_path(recipe_name: &str) -> bool { - recipe_name.contains('/') - || recipe_name.contains('\\') - || recipe_name.starts_with('~') - || recipe_name.starts_with('.') -} - -fn is_file_name(recipe_name: &str) -> bool { - Path::new(recipe_name).extension().is_some() -} - -fn read_recipe_in_dir(dir: &Path, recipe_name: &str) -> Result { - for ext in RECIPE_FILE_EXTENSIONS { - let recipe_path = dir.join(format!("{}.{}", recipe_name, ext)); - if let Ok(result) = read_recipe_file(recipe_path) { - return Ok(result); - } - } - Err(anyhow!(format!( - "No {}.yaml or {}.json recipe file found in directory: {}", - recipe_name, - recipe_name, - dir.display() - ))) -} - -fn retrieve_recipe_from_local_path(recipe_name: &str) -> Result { - let mut search_dirs = vec![PathBuf::from(".")]; - if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) { - let path_separator = if cfg!(windows) { ';' } else { ':' }; - let recipe_path_env_dirs: Vec = recipe_path_env - .split(path_separator) - .map(PathBuf::from) - .collect(); - search_dirs.extend(recipe_path_env_dirs); - } - for dir in &search_dirs { - if let Ok(result) = read_recipe_in_dir(dir, recipe_name) { - return Ok(result); - } - } - let search_dirs_str = search_dirs - .iter() - .map(|p| p.to_string_lossy()) - .collect::>() - .join(":"); - Err(anyhow!( - "ℹ️ Failed to retrieve {}.yaml or {}.json in {}", - recipe_name, - recipe_name, - search_dirs_str - )) -} - fn configured_github_recipe_repo() -> Option { let config = Config::global(); match config.get_param(GOOSE_RECIPE_GITHUB_REPO_CONFIG_KEY) { @@ -105,8 +31,22 @@ pub fn list_available_recipes() -> Result> { let mut recipes = Vec::new(); // Search local recipes - if let Ok(local_recipes) = discover_local_recipes() { - recipes.extend(local_recipes); + if let Ok(local_recipes) = list_local_recipes() { + recipes.extend(local_recipes.into_iter().map(|(path, recipe)| { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("unknown") + .to_string(); + + RecipeInfo { + name, + source: RecipeSource::Local, + path: path.to_string_lossy().to_string(), + title: Some(recipe.title), + description: Some(recipe.description), + } + })); } // Search GitHub recipes if configured @@ -118,77 +58,3 @@ pub fn list_available_recipes() -> Result> { Ok(recipes) } - -fn discover_local_recipes() -> Result> { - let mut recipes = Vec::new(); - let mut search_dirs = vec![PathBuf::from(".")]; - - // Add GOOSE_RECIPE_PATH directories - if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) { - let path_separator = if cfg!(windows) { ';' } else { ':' }; - let recipe_path_env_dirs: Vec = recipe_path_env - .split(path_separator) - .map(PathBuf::from) - .collect(); - search_dirs.extend(recipe_path_env_dirs); - } - - for dir in search_dirs { - if let Ok(dir_recipes) = scan_directory_for_recipes(&dir) { - recipes.extend(dir_recipes); - } - } - - Ok(recipes) -} - -fn scan_directory_for_recipes(dir: &Path) -> Result> { - let mut recipes = Vec::new(); - - if !dir.exists() || !dir.is_dir() { - return Ok(recipes); - } - - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - - if path.is_file() { - if let Some(extension) = path.extension() { - if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) { - if let Ok(recipe_info) = create_local_recipe_info(&path) { - recipes.push(recipe_info); - } - } - } - } - } - - Ok(recipes) -} - -fn create_local_recipe_info(path: &Path) -> Result { - let content = fs::read_to_string(path)?; - let recipe_dir = path - .parent() - .unwrap_or_else(|| Path::new(".")) - .to_string_lossy() - .to_string(); - let (recipe, _) = parse_recipe_content(&content, recipe_dir)?; - - let name = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("unknown") - .to_string(); - - let path_str = path.to_string_lossy().to_string(); - - Ok(RecipeInfo { - name, - source: RecipeSource::Local, - path: path_str, - title: Some(recipe.title), - description: Some(recipe.description), - }) -} diff --git a/crates/goose-cli/src/recipes/secret_discovery.rs b/crates/goose-cli/src/recipes/secret_discovery.rs index 6f126e76d923..6a259d21e96f 100644 --- a/crates/goose-cli/src/recipes/secret_discovery.rs +++ b/crates/goose-cli/src/recipes/secret_discovery.rs @@ -1,4 +1,4 @@ -use crate::recipes::search_recipe::retrieve_recipe_file; +use crate::recipes::search_recipe::load_recipe_file; use goose::agents::extension::ExtensionConfig; use goose::recipe::Recipe; use std::collections::HashSet; @@ -116,7 +116,7 @@ fn discover_recipe_secrets_recursive( /// For secret discovery, we only need the recipe structure (extensions and env_keys), /// not parameter-substituted content, so we parse the raw YAML directly for speed and robustness. fn load_sub_recipe(recipe_path: &str) -> Result> { - let recipe_file = retrieve_recipe_file(recipe_path)?; + let recipe_file = load_recipe_file(recipe_path)?; let recipe: Recipe = serde_yaml::from_str(&recipe_file.content)?; Ok(recipe) } diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 851bb55c95e7..26c79c8b543f 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; -use goose::recipe::recipe_library; +use goose::recipe::local_recipes; use goose::recipe::Recipe; use goose::recipe_deeplink; use goose::session::SessionManager; @@ -324,7 +324,7 @@ async fn save_recipe( None => None, }; - match recipe_library::save_recipe_to_file(request.recipe, request.is_global, file_path) { + match local_recipes::save_recipe_to_file(request.recipe, request.is_global, file_path) { Ok(_) => Ok(StatusCode::NO_CONTENT), Err(e) => Err(ErrorResponse { message: e.to_string(), diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs index c6b7827c7cc2..1a76d947a661 100644 --- a/crates/goose-server/src/routes/recipe_utils.rs +++ b/crates/goose-server/src/routes/recipe_utils.rs @@ -5,7 +5,7 @@ use std::path::PathBuf; use anyhow::Result; -use goose::recipe::recipe_library::list_all_recipes_from_library; +use goose::recipe::local_recipes::list_local_recipes; use goose::recipe::Recipe; use std::path::Path; @@ -29,7 +29,7 @@ fn short_id_from_path(path: &str) -> String { } pub fn get_all_recipes_manifests() -> Result> { - let recipes_with_path = list_all_recipes_from_library()?; + let recipes_with_path = list_local_recipes()?; let mut recipe_manifests_with_path = Vec::new(); for (file_path, recipe) in recipes_with_path { let Ok(last_modified) = fs::metadata(file_path.clone()) diff --git a/crates/goose/src/recipe/local_recipes.rs b/crates/goose/src/recipe/local_recipes.rs new file mode 100644 index 000000000000..ca7725da9df3 --- /dev/null +++ b/crates/goose/src/recipe/local_recipes.rs @@ -0,0 +1,191 @@ +use anyhow::{anyhow, Result}; +use etcetera::{choose_app_strategy, AppStrategy}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::config::APP_STRATEGY; +use crate::recipe::read_recipe_file_content::{read_recipe_file, RecipeFile}; +use crate::recipe::Recipe; +use crate::recipe::RECIPE_FILE_EXTENSIONS; +use serde_yaml; + +const GOOSE_RECIPE_PATH_ENV_VAR: &str = "GOOSE_RECIPE_PATH"; + +pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { + if is_global { + choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes") + } else { + std::env::current_dir().unwrap().join(".goose/recipes") + } +} + +fn local_recipe_dirs() -> Vec { + let mut local_dirs = vec![PathBuf::from(".")]; + + if let Ok(recipe_path_env) = env::var(GOOSE_RECIPE_PATH_ENV_VAR) { + let path_separator = if cfg!(windows) { ';' } else { ':' }; + local_dirs.extend(recipe_path_env.split(path_separator).map(PathBuf::from)); + } + local_dirs.push(get_recipe_library_dir(true)); + local_dirs.push(get_recipe_library_dir(false)); + + local_dirs +} + +pub fn load_local_recipe_file(recipe_name: &str) -> Result { + if RECIPE_FILE_EXTENSIONS + .iter() + .any(|ext| recipe_name.ends_with(&format!(".{}", ext))) + { + let path = PathBuf::from(recipe_name); + return read_recipe_file(path); + } + + if is_file_path(recipe_name) || is_file_name(recipe_name) { + return Err(anyhow!( + "Recipe file {} is not a json or yaml file", + recipe_name + )); + } + + let search_dirs = local_recipe_dirs(); + for dir in &search_dirs { + if let Ok(result) = load_recipe_file_from_dir(dir, recipe_name) { + return Ok(result); + } + } + + let search_dirs_str = search_dirs + .iter() + .map(|p| p.to_string_lossy()) + .collect::>() + .join(":"); + Err(anyhow!( + "ℹ️ Failed to retrieve {}.yaml or {}.json in {}", + recipe_name, + recipe_name, + search_dirs_str + )) +} + +pub fn list_local_recipes() -> Result> { + let mut recipes = Vec::new(); + for dir in local_recipe_dirs() { + if let Ok(dir_recipes) = scan_directory_for_recipes(&dir) { + recipes.extend(dir_recipes); + } + } + + Ok(recipes) +} + +fn is_file_path(recipe_name: &str) -> bool { + recipe_name.contains('/') + || recipe_name.contains('\\') + || recipe_name.starts_with('~') + || recipe_name.starts_with('.') +} + +fn is_file_name(recipe_name: &str) -> bool { + Path::new(recipe_name).extension().is_some() +} + +fn load_recipe_file_from_dir(dir: &Path, recipe_name: &str) -> Result { + for ext in RECIPE_FILE_EXTENSIONS { + let recipe_path = dir.join(format!("{}.{}", recipe_name, ext)); + if let Ok(result) = read_recipe_file(recipe_path) { + return Ok(result); + } + } + Err(anyhow!(format!( + "No {}.yaml or {}.json recipe file found in directory: {}", + recipe_name, + recipe_name, + dir.display() + ))) +} + +fn scan_directory_for_recipes(dir: &Path) -> Result> { + let mut recipes = Vec::new(); + + if !dir.exists() || !dir.is_dir() { + return Ok(recipes); + } + + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(extension) = path.extension() { + if RECIPE_FILE_EXTENSIONS.contains(&extension.to_string_lossy().as_ref()) { + if let Ok(recipe) = Recipe::from_file_path(&path) { + recipes.push((path.clone(), recipe)); + } + } + } + } + } + + Ok(recipes) +} + +fn generate_recipe_filename(title: &str) -> String { + let base_name = title + .to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-') + .collect::() + .split_whitespace() + .collect::>() + .join("-"); + + let filename = if base_name.is_empty() { + "untitled-recipe".to_string() + } else { + base_name + }; + format!("{}.yaml", filename) +} + +pub fn save_recipe_to_file( + recipe: Recipe, + is_global: Option, + file_path: Option, +) -> anyhow::Result { + let is_global_value = is_global.unwrap_or(true); + + let default_file_path = + get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); + + let file_path_value = match file_path { + Some(path) => path, + None => { + if default_file_path.exists() { + return Err(anyhow::anyhow!( + "Recipe file already exists at: {:?}", + default_file_path + )); + } + default_file_path + } + }; + let all_recipes = list_local_recipes()?; + + for (existing_path, existing_recipe) in &all_recipes { + if existing_recipe.title == recipe.title && existing_path != &file_path_value { + return Err(anyhow::anyhow!( + "Recipe with title '{}' already exists", + recipe.title + )); + } + } + + let yaml_content = serde_yaml::to_string(&recipe)?; + fs::write(&file_path_value, yaml_content)?; + Ok(file_path_value) +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 6995de843d22..71d4980bb178 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -2,20 +2,23 @@ use anyhow::Result; use serde_json::Value; use std::collections::HashMap; use std::fmt; +use std::path::Path; use crate::agents::extension::ExtensionConfig; use crate::agents::types::RetryConfig; +use crate::recipe::read_recipe_file_content::read_recipe_file; use crate::utils::contains_unicode_tags; use serde::de::Deserializer; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub mod build_recipe; +pub mod local_recipes; pub mod read_recipe_file_content; -pub mod recipe_library; pub mod template_recipe; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; +pub const RECIPE_FILE_EXTENSIONS: &[&str] = &["yaml", "json"]; fn default_version() -> String { "1.0.0".to_string() @@ -308,6 +311,12 @@ impl Recipe { retry: None, } } + + pub fn from_file_path(file_path: &Path) -> Result { + let file = read_recipe_file(file_path)?; + Self::from_content(&file.content) + } + pub fn from_content(content: &str) -> Result { let recipe: Recipe = if let Ok(json_value) = serde_json::from_str::(content) { diff --git a/crates/goose/src/recipe/recipe_library.rs b/crates/goose/src/recipe/recipe_library.rs deleted file mode 100644 index 8a615957b0e7..000000000000 --- a/crates/goose/src/recipe/recipe_library.rs +++ /dev/null @@ -1,104 +0,0 @@ -use crate::config::APP_STRATEGY; -use crate::recipe::read_recipe_file_content::read_recipe_file; -use crate::recipe::Recipe; -use anyhow::Result; -use etcetera::{choose_app_strategy, AppStrategy}; -use serde_yaml; -use std::fs; -use std::path::PathBuf; - -pub fn get_recipe_library_dir(is_global: bool) -> PathBuf { - if is_global { - choose_app_strategy(APP_STRATEGY.clone()) - .expect("goose requires a home dir") - .config_dir() - .join("recipes") - } else { - std::env::current_dir().unwrap().join(".goose/recipes") - } -} - -pub fn list_recipes_from_library(is_global: bool) -> Result> { - let path = get_recipe_library_dir(is_global); - let mut recipes_with_path = Vec::new(); - if path.exists() { - for entry in fs::read_dir(path)? { - let path = entry?.path(); - let extension = path.extension(); - - if extension == Some("yaml".as_ref()) || extension == Some("json".as_ref()) { - let Ok(recipe_file) = read_recipe_file(path.clone()) else { - continue; - }; - let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { - continue; - }; - recipes_with_path.push((path, recipe)); - } - } - } - Ok(recipes_with_path) -} - -pub fn list_all_recipes_from_library() -> Result> { - let mut recipes_with_path = Vec::new(); - recipes_with_path.extend(list_recipes_from_library(true)?); - recipes_with_path.extend(list_recipes_from_library(false)?); - Ok(recipes_with_path) -} - -fn generate_recipe_filename(title: &str) -> String { - let base_name = title - .to_lowercase() - .chars() - .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-') - .collect::() - .split_whitespace() - .collect::>() - .join("-"); - - let filename = if base_name.is_empty() { - "untitled-recipe".to_string() - } else { - base_name - }; - format!("{}.yaml", filename) -} - -pub fn save_recipe_to_file( - recipe: Recipe, - is_global: Option, - file_path: Option, -) -> anyhow::Result { - let is_global_value = is_global.unwrap_or(true); - - let default_file_path = - get_recipe_library_dir(is_global_value).join(generate_recipe_filename(&recipe.title)); - - let file_path_value = match file_path { - Some(path) => path, - None => { - if default_file_path.exists() { - return Err(anyhow::anyhow!( - "Recipe file already exists at: {:?}", - default_file_path - )); - } - default_file_path - } - }; - let all_recipes = list_all_recipes_from_library()?; - - for (existing_path, existing_recipe) in &all_recipes { - if existing_recipe.title == recipe.title && existing_path != &file_path_value { - return Err(anyhow::anyhow!( - "Recipe with title '{}' already exists", - recipe.title - )); - } - } - - let yaml_content = serde_yaml::to_string(&recipe)?; - fs::write(&file_path_value, yaml_content)?; - Ok(file_path_value) -}