diff --git a/Cargo.lock b/Cargo.lock
index 3e417bacd754..479b7db5558d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2817,6 +2817,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
+ "serde_path_to_error",
"serde_yaml",
"tempfile",
"thiserror 1.0.69",
@@ -5867,18 +5868,28 @@ checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
-version = "1.0.218"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.218"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
@@ -5911,12 +5922,13 @@ dependencies = [
[[package]]
name = "serde_path_to_error"
-version = "0.1.17"
+version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
+ "serde_core",
]
[[package]]
diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs
index 4b2e5e0144fe..5688645e19bb 100644
--- a/crates/goose-cli/src/cli.rs
+++ b/crates/goose-cli/src/cli.rs
@@ -1019,7 +1019,7 @@ pub async fn cli() -> Result<()> {
.and_then(|rf| {
goose::recipe::template_recipe::parse_recipe_content(
&rf.content,
- rf.parent_dir.to_string_lossy().to_string(),
+ Some(rf.parent_dir.to_string_lossy().to_string()),
)
.ok()
.map(|(r, _)| r.version)
diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs
index a6fd629d7542..caeeddeaa63c 100644
--- a/crates/goose-cli/src/commands/recipe.rs
+++ b/crates/goose-cli/src/commands/recipe.rs
@@ -1,43 +1,25 @@
use anyhow::Result;
use console::style;
+use goose::recipe::validate_recipe::validate_recipe_template_from_file;
use crate::recipes::github_recipe::RecipeSource;
-use crate::recipes::recipe::load_recipe_for_validation;
-use crate::recipes::search_recipe::list_available_recipes;
+use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file};
use goose::recipe_deeplink;
-/// Validates a recipe file
-///
-/// # Arguments
-///
-/// * `file_path` - Path to the recipe file to validate
-///
-/// # Returns
-///
-/// Result indicating success or failure
pub fn handle_validate(recipe_name: &str) -> Result<()> {
// Load and validate the recipe file
- match load_recipe_for_validation(recipe_name) {
- Ok(_) => {
- println!("{} recipe file is valid", style("✓").green().bold());
- Ok(())
- }
- Err(err) => {
- println!("{} {}", style("✗").red().bold(), err);
- Err(err)
- }
- }
+ let recipe_file = load_recipe_file(recipe_name)?;
+ validate_recipe_template_from_file(&recipe_file).map_err(|err| {
+ anyhow::anyhow!(
+ "{} recipe file is invalid: {}",
+ style("✗").red().bold(),
+ err
+ )
+ })?;
+ println!("{} recipe file is valid", style("✓").green().bold());
+ Ok(())
}
-/// Generates a deeplink for a recipe file
-///
-/// # Arguments
-///
-/// * `recipe_name` - Path to the recipe file
-///
-/// # Returns
-///
-/// Result indicating success or failure
pub fn handle_deeplink(recipe_name: &str) -> Result {
match generate_deeplink(recipe_name) {
Ok((deeplink_url, recipe)) => {
@@ -60,15 +42,6 @@ pub fn handle_deeplink(recipe_name: &str) -> Result {
}
}
-/// 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
@@ -107,16 +80,6 @@ pub fn handle_open(recipe_name: &str) -> Result<()> {
}
}
-/// Lists all available recipes from local paths and GitHub repositories
-///
-/// # Arguments
-///
-/// * `format` - Output format ("text" or "json")
-/// * `verbose` - Whether to show detailed information
-///
-/// # Returns
-///
-/// Result indicating success or failure
pub fn handle_list(format: &str, verbose: bool) -> Result<()> {
let recipes = match list_available_recipes() {
Ok(recipes) => recipes,
@@ -168,18 +131,10 @@ 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)> {
+ let recipe_file = load_recipe_file(recipe_name)?;
// Load the recipe file first to validate it
- let recipe = load_recipe_for_validation(recipe_name)?;
+ let recipe = validate_recipe_template_from_file(&recipe_file)?;
match recipe_deeplink::encode(&recipe) {
Ok(encoded) => {
let full_url = format!("goose://recipe?config={}", encoded);
@@ -227,20 +182,6 @@ prompt: "Test prompt content {{ name }}"
instructions: "Test instructions"
"#;
- const RECIPE_WITH_INVALID_JSON_SCHEMA: &str = r#"
-title: "Test Recipe with Invalid JSON Schema"
-description: "A test recipe with invalid JSON schema"
-prompt: "Test prompt content"
-instructions: "Test instructions"
-response:
- json_schema:
- type: invalid_type
- properties:
- result:
- type: unknown_type
- required: "should_be_array_not_string"
-"#;
-
#[test]
fn test_handle_deeplink_valid_recipe() {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
@@ -305,23 +246,6 @@ response:
assert!(result.is_err());
}
- #[test]
- fn test_handle_validation_recipe_with_invalid_json_schema() {
- let temp_dir = TempDir::new().expect("Failed to create temp directory");
- let recipe_path = create_test_recipe_file(
- &temp_dir,
- "test_recipe.yaml",
- RECIPE_WITH_INVALID_JSON_SCHEMA,
- );
-
- let result = handle_validate(&recipe_path);
- assert!(result.is_err());
- assert!(result
- .unwrap_err()
- .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");
diff --git a/crates/goose-cli/src/recipes/github_recipe.rs b/crates/goose-cli/src/recipes/github_recipe.rs
index c7f88b823fe4..bfe297d8dd89 100644
--- a/crates/goose-cli/src/recipes/github_recipe.rs
+++ b/crates/goose-cli/src/recipes/github_recipe.rs
@@ -331,7 +331,7 @@ fn get_github_recipe_info(repo: &str, dir_name: &str, recipe_filename: &str) ->
.map_err(|e| anyhow!("Failed to convert content to string: {}", e))?;
// Parse the recipe content
- let (recipe, _) = parse_recipe_content(&content, format!("{}/{}", repo, dir_name))?;
+ let (recipe, _) = parse_recipe_content(&content, Some(format!("{}/{}", repo, dir_name)))?;
return Ok(RecipeInfo {
name: dir_name.to_string(),
diff --git a/crates/goose-cli/src/recipes/recipe.rs b/crates/goose-cli/src/recipes/recipe.rs
index 32b05f551561..8b1fe3a6ab18 100644
--- a/crates/goose-cli/src/recipes/recipe.rs
+++ b/crates/goose-cli/src/recipes/recipe.rs
@@ -7,13 +7,13 @@ use crate::recipes::secret_discovery::{discover_recipe_secrets, SecretRequiremen
use anyhow::Result;
use goose::config::Config;
use goose::recipe::build_recipe::{
- apply_values_to_parameters, build_recipe_from_template, validate_recipe_parameters, RecipeError,
+ apply_values_to_parameters, build_recipe_from_template, RecipeError,
};
use goose::recipe::read_recipe_file_content::RecipeFile;
use goose::recipe::template_recipe::render_recipe_for_preview;
+use goose::recipe::validate_recipe::validate_recipe_parameters;
use goose::recipe::Recipe;
use serde_json::Value;
-use std::collections::HashMap;
fn create_user_prompt_callback() -> impl Fn(&str, &str) -> Result {
|key: &str, description: &str| -> Result {
@@ -131,29 +131,11 @@ pub fn render_recipe_as_yaml(recipe_name: &str, params: Vec<(String, String)>) -
}
}
-pub fn load_recipe_for_validation(recipe_name: &str) -> Result {
- let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
- let recipe_file_content = &recipe_file.content;
- validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?;
- let recipe = render_recipe_for_preview(
- recipe_file_content,
- recipe_dir_str.to_string(),
- &HashMap::new(),
- )?;
-
- if let Some(response) = &recipe.response {
- if let Some(json_schema) = &response.json_schema {
- validate_json_schema(json_schema)?;
- }
- }
-
- Ok(recipe)
-}
-
pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Result<()> {
let (recipe_file, recipe_dir_str) = load_recipe_file_with_dir(recipe_name)?;
let recipe_file_content = &recipe_file.content;
- let recipe_parameters = validate_recipe_parameters(recipe_file_content, &recipe_dir_str)?;
+ let recipe_parameters =
+ validate_recipe_parameters(recipe_file_content, Some(recipe_dir_str.clone()))?;
let (params_for_template, missing_params) = apply_values_to_parameters(
¶ms,
@@ -163,7 +145,7 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
)?;
let recipe = render_recipe_for_preview(
recipe_file_content,
- recipe_dir_str.to_string(),
+ Some(recipe_dir_str.clone()),
¶ms_for_template,
)?;
print_recipe_explanation(&recipe);
@@ -172,13 +154,6 @@ pub fn explain_recipe(recipe_name: &str, params: Vec<(String, String)>) -> Resul
Ok(())
}
-fn validate_json_schema(schema: &serde_json::Value) -> Result<()> {
- match jsonschema::validator_for(schema) {
- Ok(_) => Ok(()),
- Err(err) => Err(anyhow::anyhow!("JSON schema validation failed: {}", err)),
- }
-}
-
#[cfg(test)]
mod tests {
use goose::recipe::{RecipeParameterInputType, RecipeParameterRequirement};
diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml
index 611ed4fdf9fc..eefd68f938fc 100644
--- a/crates/goose-server/Cargo.toml
+++ b/crates/goose-server/Cargo.toml
@@ -39,6 +39,7 @@ utoipa = { version = "4.1", features = ["axum_extras", "chrono"] }
reqwest = { version = "0.12.9", features = ["json", "rustls-tls", "blocking", "multipart"], default-features = false }
tokio-util = "0.7.15"
uuid = { version = "1.11", features = ["v4"] }
+serde_path_to_error = "0.1.20"
[[bin]]
name = "goosed"
diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs
index 26c79c8b543f..1c6da4dbbbcc 100644
--- a/crates/goose-server/src/routes/recipe.rs
+++ b/crates/goose-server/src/routes/recipe.rs
@@ -1,17 +1,42 @@
use std::collections::HashMap;
use std::fs;
+use std::path::PathBuf;
use std::sync::Arc;
+use axum::extract::rejection::JsonRejection;
use axum::routing::get;
use axum::{extract::State, http::StatusCode, routing::post, Json, Router};
use goose::recipe::local_recipes;
+use goose::recipe::validate_recipe::validate_recipe_template_from_content;
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::SessionManager;
use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use serde_path_to_error::deserialize as deserialize_with_path;
use utoipa::ToSchema;
+fn format_json_rejection_message(rejection: &JsonRejection) -> String {
+ match rejection {
+ JsonRejection::JsonDataError(err) => {
+ format!("Request body validation failed: {}", clean_data_error(err))
+ }
+ JsonRejection::JsonSyntaxError(err) => format!("Invalid JSON payload: {}", err.body_text()),
+ JsonRejection::MissingJsonContentType(err) => err.body_text(),
+ JsonRejection::BytesRejection(err) => err.body_text(),
+ _ => rejection.body_text(),
+ }
+}
+
+fn clean_data_error(err: &axum::extract::rejection::JsonDataError) -> String {
+ let message = err.body_text();
+ message
+ .strip_prefix("Failed to deserialize the JSON body into the target type: ")
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| message.to_string())
+}
+
use crate::routes::errors::ErrorResponse;
use crate::routes::recipe_utils::get_all_recipes_manifests;
use crate::state::AppState;
@@ -19,7 +44,6 @@ use crate::state::AppState;
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateRecipeRequest {
session_id: String,
- // Optional fields
#[serde(default)]
author: Option,
}
@@ -72,7 +96,6 @@ pub struct ScanRecipeResponse {
pub struct SaveRecipeRequest {
recipe: Recipe,
id: Option,
- is_global: Option,
}
#[derive(Debug, Deserialize, ToSchema)]
pub struct ParseRecipeRequest {
@@ -86,7 +109,6 @@ pub struct ParseRecipeResponse {
#[derive(Debug, Serialize, ToSchema)]
pub struct RecipeManifestResponse {
- name: String,
recipe: Recipe,
#[serde(rename = "lastModified")]
last_modified: String,
@@ -115,7 +137,6 @@ pub struct ListRecipeResponse {
),
tag = "Recipe Management"
)]
-/// Create a Recipe configuration from the current session
async fn create_recipe(
State(state): State>,
Json(request): Json,
@@ -125,7 +146,6 @@ async fn create_recipe(
request.session_id
);
- // Load messages from session
let session = match SessionManager::get_session(&request.session_id, true).await {
Ok(session) => session,
Err(e) => {
@@ -148,7 +168,6 @@ async fn create_recipe(
let agent = state.get_agent_for_route(request.session_id).await?;
- // Create base recipe from agent state and messages
let recipe_result = agent.create_recipe(conversation).await;
match recipe_result {
@@ -261,7 +280,6 @@ async fn list_recipes(
let file_path = recipe_manifest_with_path.file_path.clone();
recipe_file_hash_map.insert(id.clone(), file_path);
RecipeManifestResponse {
- name: recipe_manifest_with_path.name.clone(),
recipe: recipe_manifest_with_path.recipe.clone(),
id: id.clone(),
last_modified: recipe_manifest_with_path.last_modified.clone(),
@@ -291,10 +309,9 @@ async fn delete_recipe(
State(state): State>,
Json(request): Json,
) -> StatusCode {
- let recipe_file_hash_map = state.recipe_file_hash_map.lock().await;
- let file_path = match recipe_file_hash_map.get(&request.id) {
- Some(path) => path,
- None => return StatusCode::NOT_FOUND,
+ let file_path = match get_recipe_file_path_by_id(state.clone(), &request.id).await {
+ Ok(path) => path,
+ Err(err) => return err.status,
};
if fs::remove_file(file_path).is_err() {
@@ -317,14 +334,18 @@ async fn delete_recipe(
)]
async fn save_recipe(
State(state): State>,
- Json(request): Json,
+ payload: Result, JsonRejection>,
) -> Result {
- let file_path = match request.id {
- Some(id) => state.recipe_file_hash_map.lock().await.get(&id).cloned(),
+ let Json(raw_json) = payload.map_err(json_rejection_to_error_response)?;
+ let request = deserialize_save_recipe_request(raw_json)?;
+ validate_recipe(&request.recipe)?;
+
+ let file_path = match request.id.as_ref() {
+ Some(id) => Some(get_recipe_file_path_by_id(state.clone(), id).await?),
None => None,
};
- match local_recipes::save_recipe_to_file(request.recipe, request.is_global, file_path) {
+ match local_recipes::save_recipe_to_file(request.recipe, file_path) {
Ok(_) => Ok(StatusCode::NO_CONTENT),
Err(e) => Err(ErrorResponse {
message: e.to_string(),
@@ -333,6 +354,85 @@ async fn save_recipe(
}
}
+fn json_rejection_to_error_response(rejection: JsonRejection) -> ErrorResponse {
+ ErrorResponse {
+ message: format_json_rejection_message(&rejection),
+ status: StatusCode::BAD_REQUEST,
+ }
+}
+
+fn validate_recipe(recipe: &Recipe) -> Result<(), ErrorResponse> {
+ let recipe_json = serde_json::to_string(recipe).map_err(|err| ErrorResponse {
+ message: err.to_string(),
+ status: StatusCode::BAD_REQUEST,
+ })?;
+
+ validate_recipe_template_from_content(&recipe_json, None).map_err(|err| ErrorResponse {
+ message: err.to_string(),
+ status: StatusCode::BAD_REQUEST,
+ })?;
+
+ Ok(())
+}
+
+fn deserialize_save_recipe_request(value: Value) -> Result {
+ let payload = value.to_string();
+ let mut deserializer = serde_json::Deserializer::from_str(&payload);
+ let result: Result = deserialize_with_path(&mut deserializer);
+ result.map_err(|err| {
+ let path = err.path().to_string();
+ let inner = err.into_inner();
+ let message = if path.is_empty() {
+ format!("Save recipe validation failed: {}", inner)
+ } else {
+ format!(
+ "save recipe validation failed at {}: {}",
+ path.trim_start_matches('.'),
+ inner
+ )
+ };
+ ErrorResponse {
+ message,
+ status: StatusCode::BAD_REQUEST,
+ }
+ })
+}
+
+async fn get_recipe_file_path_by_id(
+ state: Arc,
+ id: &str,
+) -> Result {
+ let cached_path = {
+ let map = state.recipe_file_hash_map.lock().await;
+ map.get(id).cloned()
+ };
+
+ if let Some(path) = cached_path {
+ return Ok(path);
+ }
+
+ let recipe_manifest_with_paths = get_all_recipes_manifests().unwrap_or_default();
+ let mut recipe_file_hash_map = HashMap::new();
+ let mut resolved_path: Option = None;
+
+ for recipe_manifest_with_path in &recipe_manifest_with_paths {
+ if recipe_manifest_with_path.id == id {
+ resolved_path = Some(recipe_manifest_with_path.file_path.clone());
+ }
+ recipe_file_hash_map.insert(
+ recipe_manifest_with_path.id.clone(),
+ recipe_manifest_with_path.file_path.clone(),
+ );
+ }
+
+ state.set_recipe_file_hash_map(recipe_file_hash_map).await;
+
+ resolved_path.ok_or_else(|| ErrorResponse {
+ message: format!("Recipe not found: {}", id),
+ status: StatusCode::NOT_FOUND,
+ })
+}
+
#[utoipa::path(
post,
path = "/recipes/parse",
@@ -347,9 +447,11 @@ async fn save_recipe(
async fn parse_recipe(
Json(request): Json,
) -> Result, ErrorResponse> {
- let recipe = Recipe::from_content(&request.content).map_err(|e| ErrorResponse {
- message: format!("Invalid recipe format: {}", e),
- status: StatusCode::BAD_REQUEST,
+ let recipe = validate_recipe_template_from_content(&request.content, None).map_err(|e| {
+ ErrorResponse {
+ message: format!("Invalid recipe format: {}", e),
+ status: StatusCode::BAD_REQUEST,
+ }
})?;
Ok(Json(ParseRecipeResponse { recipe }))
diff --git a/crates/goose-server/src/routes/recipe_utils.rs b/crates/goose-server/src/routes/recipe_utils.rs
index 1a76d947a661..49181a246986 100644
--- a/crates/goose-server/src/routes/recipe_utils.rs
+++ b/crates/goose-server/src/routes/recipe_utils.rs
@@ -8,14 +8,8 @@ use anyhow::Result;
use goose::recipe::local_recipes::list_local_recipes;
use goose::recipe::Recipe;
-use std::path::Path;
-
-use serde::{Deserialize, Serialize};
-use utoipa::ToSchema;
-
pub struct RecipeManifestWithPath {
pub id: String,
- pub name: String,
pub recipe: Recipe,
pub file_path: PathBuf,
pub last_modified: String,
@@ -37,16 +31,9 @@ pub fn get_all_recipes_manifests() -> Result> {
else {
continue;
};
- let recipe_metadata =
- RecipeManifestMetadata::from_yaml_file(&file_path).unwrap_or_else(|_| {
- RecipeManifestMetadata {
- name: recipe.title.clone(),
- }
- });
let manifest_with_path = RecipeManifestWithPath {
id: short_id_from_path(file_path.to_string_lossy().as_ref()),
- name: recipe_metadata.name,
recipe,
file_path,
last_modified,
@@ -57,44 +44,3 @@ pub fn get_all_recipes_manifests() -> Result> {
Ok(recipe_manifests_with_path)
}
-
-// this is a temporary struct to deserilize the UI recipe files. should not be used for other purposes.
-#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)]
-struct RecipeManifestMetadata {
- pub name: String,
-}
-
-impl RecipeManifestMetadata {
- pub fn from_yaml_file(path: &Path) -> Result {
- let content = fs::read_to_string(path)
- .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?;
- let metadata = serde_yaml::from_str::(&content)
- .map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?;
- Ok(metadata)
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::fs;
- use tempfile::tempdir;
-
- #[test]
- fn test_from_yaml_file_success() {
- let temp_dir = tempdir().unwrap();
- let file_path = temp_dir.path().join("test_recipe.yaml");
-
- let yaml_content = r#"
-name: "Test Recipe"
-isGlobal: true
-recipe: recipe_content
-"#;
-
- fs::write(&file_path, yaml_content).unwrap();
-
- let result = RecipeManifestMetadata::from_yaml_file(&file_path).unwrap();
-
- assert_eq!(result.name, "Test Recipe");
- }
-}
diff --git a/crates/goose-server/src/routes/schedule.rs b/crates/goose-server/src/routes/schedule.rs
index 5203e8be703b..a9ce8f399bec 100644
--- a/crates/goose-server/src/routes/schedule.rs
+++ b/crates/goose-server/src/routes/schedule.rs
@@ -237,11 +237,13 @@ async fn run_now_handler(
.and_then(|content| {
goose::recipe::template_recipe::parse_recipe_content(
&content,
- std::path::Path::new(&job.source)
- .parent()
- .unwrap_or_else(|| std::path::Path::new(""))
- .to_string_lossy()
- .to_string(),
+ Some(
+ std::path::Path::new(&job.source)
+ .parent()
+ .unwrap_or_else(|| std::path::Path::new(""))
+ .to_string_lossy()
+ .to_string(),
+ ),
)
.ok()
.map(|(r, _)| r.version)
diff --git a/crates/goose/src/recipe/build_recipe/mod.rs b/crates/goose/src/recipe/build_recipe/mod.rs
index 220148742a37..05b23ff750f2 100644
--- a/crates/goose/src/recipe/build_recipe/mod.rs
+++ b/crates/goose/src/recipe/build_recipe/mod.rs
@@ -1,11 +1,12 @@
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
-use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
+use crate::recipe::template_recipe::render_recipe_content_with_params;
+use crate::recipe::validate_recipe::validate_recipe_template_from_content;
use crate::recipe::{
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
BUILT_IN_RECIPE_DIR_PARAM,
};
use anyhow::Result;
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
use std::path::Path;
#[derive(Debug, thiserror::Error)]
@@ -34,7 +35,11 @@ where
let recipe_dir_str = recipe_parent_dir
.to_str()
.ok_or_else(|| anyhow::anyhow!("Error getting recipe directory"))?;
- let recipe_parameters = validate_recipe_parameters(&recipe_file_content, recipe_dir_str)?;
+ let recipe_parameters = validate_recipe_template_from_content(
+ &recipe_file_content,
+ Some(recipe_dir_str.to_string()),
+ )?
+ .parameters;
let (params_for_template, missing_params) =
apply_values_to_parameters(¶ms, recipe_parameters, recipe_dir_str, user_prompt_fn)?;
@@ -48,18 +53,6 @@ where
Ok((rendered_content, missing_params))
}
-pub fn validate_recipe_parameters(
- recipe_file_content: &str,
- recipe_dir_str: &str,
-) -> Result
-
-
- {(field) => {
- // Store reference to the field for programmatic updates
- recipeTitleFieldRef = field;
-
- return (
-
- typeof error === 'string' ? error : error?.message || String(error)
- )}
- />
- );
- }}
-
-
-
- {(field) => (
-
- )}
-
diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx
index fa2a4a804b98..c6b2eba20090 100644
--- a/ui/desktop/src/components/recipes/RecipesView.tsx
+++ b/ui/desktop/src/components/recipes/RecipesView.tsx
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
-import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipeStorage';
+import { listSavedRecipes, convertToLocaleDateString } from '../../recipe/recipe_management';
import { FileText, Edit, Trash2, Play, Calendar, AlertCircle, Link } from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
@@ -65,7 +65,7 @@ export default function RecipesView() {
}
};
- const handleLoadRecipe = async (recipe: Recipe) => {
+ const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
@@ -82,7 +82,8 @@ export default function RecipesView() {
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
- undefined // view type
+ undefined, // view type,
+ recipeId // recipe id
);
// }
} catch (err) {
@@ -98,7 +99,7 @@ export default function RecipesView() {
buttons: ['Cancel', 'Delete'],
defaultId: 0,
title: 'Delete Recipe',
- message: `Are you sure you want to delete "${recipeManifest.name}"?`,
+ message: `Are you sure you want to delete "${recipeManifest.recipe.title}"?`,
detail: 'Recipe file will be deleted.',
});
@@ -110,7 +111,7 @@ export default function RecipesView() {
await deleteRecipe({ body: { id: recipeManifest.id } });
await loadSavedRecipes();
toastSuccess({
- title: recipeManifest.name,
+ title: recipeManifest.recipe.title,
msg: 'Recipe deleted successfully',
});
} catch (err) {
@@ -174,7 +175,7 @@ export default function RecipesView() {
)}
-
- {/* Recipe Name Field */}
-
- {(field: FormFieldApi) => (
-
-
- {
- field.handleChange(value);
- onRecipeNameChange?.(value);
- }}
- onBlur={field.handleBlur}
- errors={field.state.meta.errors}
- />
-
-
- )}
-
-
- {/* Save Location Field */}
-
- {(field: FormFieldApi) => (
-
- )}
-
);
}
diff --git a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx b/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx
deleted file mode 100644
index 52fc68cef083..000000000000
--- a/ui/desktop/src/components/recipes/shared/RecipeTitleField.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-interface RecipeTitleFieldProps {
- id: string;
- value: string;
- onChange: (value: string) => void;
- onBlur: () => void;
- errors: string[];
- label?: string;
- required?: boolean;
- disabled?: boolean;
-}
-
-export function RecipeTitleField({
- id,
- value,
- onChange,
- onBlur,
- errors,
- label = 'Recipe Title',
- required = true,
- disabled = false,
-}: RecipeTitleFieldProps) {
- return (
-
-
-
onChange(e.target.value)}
- onBlur={onBlur}
- disabled={disabled}
- className={`w-full p-3 border rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500 ${
- errors.length > 0 ? 'border-red-500' : 'border-border-subtle'
- } ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
- placeholder="My Recipe Title"
- />
-
- This will be the display name shown in your recipe library
-
- {errors.length > 0 &&
{errors[0]}
}
-
- );
-}
diff --git a/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx b/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx
deleted file mode 100644
index 2e5c599e6bd9..000000000000
--- a/ui/desktop/src/components/recipes/shared/SaveRecipeDialog.tsx
+++ /dev/null
@@ -1,212 +0,0 @@
-import React, { useState } from 'react';
-import { Button } from '../../ui/button';
-import { Recipe } from '../../../recipe';
-import { saveRecipe, generateRecipeFilename } from '../../../recipe/recipeStorage';
-import { toastSuccess, toastError } from '../../../toasts';
-import { useEscapeKey } from '../../../hooks/useEscapeKey';
-import { Play } from 'lucide-react';
-
-interface SaveRecipeDialogProps {
- isOpen: boolean;
- onClose: (wasSaved?: boolean) => void;
- onSuccess?: () => void;
- recipe: Recipe;
- suggestedName?: string;
- showSaveAndRun?: boolean;
- onSaveAndRun?: (recipe: Recipe) => void;
-}
-
-export default function SaveRecipeDialog({
- isOpen,
- onClose,
- onSuccess,
- recipe,
- suggestedName,
- showSaveAndRun = false,
- onSaveAndRun,
-}: SaveRecipeDialogProps) {
- const [saveRecipeName, setSaveRecipeName] = useState(
- suggestedName || generateRecipeFilename(recipe)
- );
- const [saveGlobal, setSaveGlobal] = useState(true);
- const [saving, setSaving] = useState(false);
-
- useEscapeKey(isOpen, onClose);
-
- React.useEffect(() => {
- if (isOpen) {
- setSaveRecipeName(suggestedName || generateRecipeFilename(recipe));
- setSaveGlobal(true);
- setSaving(false);
- }
- }, [isOpen, suggestedName, recipe]);
-
- const handleSaveRecipe = async () => {
- if (!saveRecipeName.trim()) {
- return;
- }
-
- setSaving(true);
- try {
- if (!recipe.title || !recipe.description || !recipe.instructions) {
- throw new Error('Invalid recipe configuration: missing required fields');
- }
-
- await saveRecipe(recipe, {
- name: saveRecipeName.trim(),
- global: saveGlobal,
- });
-
- setSaveRecipeName('');
- onClose(true);
-
- toastSuccess({
- title: saveRecipeName.trim(),
- msg: 'Recipe saved successfully',
- });
-
- onSuccess?.();
- } catch (error) {
- console.error('Failed to save recipe:', error);
-
- toastError({
- title: 'Save Failed',
- msg: `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
- traceback: error instanceof Error ? error.message : String(error),
- });
- } finally {
- setSaving(false);
- }
- };
-
- const handleSaveAndRunRecipe = async () => {
- if (!saveRecipeName.trim()) {
- return;
- }
-
- setSaving(true);
- try {
- if (!recipe.title || !recipe.description || !recipe.instructions) {
- throw new Error('Invalid recipe configuration: missing required fields');
- }
-
- await saveRecipe(recipe, {
- name: saveRecipeName.trim(),
- global: saveGlobal,
- });
-
- setSaveRecipeName('');
- onClose(true);
-
- toastSuccess({
- title: saveRecipeName.trim(),
- msg: 'Recipe saved and launched successfully',
- });
-
- // Launch the recipe in a new window
- onSaveAndRun?.(recipe);
- onSuccess?.();
- } catch (error) {
- console.error('Failed to save and run recipe:', error);
-
- toastError({
- title: 'Save and Run Failed',
- msg: `Failed to save and run recipe: ${error instanceof Error ? error.message : 'Unknown error'}`,
- traceback: error instanceof Error ? error.message : String(error),
- });
- } finally {
- setSaving(false);
- }
- };
-
- const handleClose = () => {
- setSaveRecipeName('');
- onClose();
- };
-
- if (!isOpen) return null;
-
- return (
-
-
-
Save Recipe
-
-
-
-
- setSaveRecipeName(e.target.value)}
- className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
- placeholder="Enter recipe name"
- autoFocus
- />
-
-
-
-
-
-
-
-
- {showSaveAndRun && (
-
- )}
-
-
-
- );
-}
diff --git a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
index f46c507074a9..20dedcd8d8e1 100644
--- a/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
+++ b/ui/desktop/src/components/recipes/shared/__tests__/RecipeFormFields.test.tsx
@@ -16,8 +16,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
- recipeName: '',
- global: true,
...initialValues,
};
@@ -129,19 +127,6 @@ describe('RecipeFormFields', () => {
});
});
- describe('Always Visible Fields', () => {
- it('always shows recipe name field', () => {
- render();
- expect(screen.getByText('Recipe Name')).toBeInTheDocument();
- });
-
- it('always shows save location field', () => {
- render();
- expect(screen.getByText('Save Location')).toBeInTheDocument();
- expect(screen.getByText('Global - Available across all Goose sessions')).toBeInTheDocument();
- });
- });
-
describe('Pre-filled Values', () => {
it('displays pre-filled form values', () => {
const initialValues: Partial = {
@@ -262,8 +247,6 @@ describe('RecipeFormFields', () => {
activities: [],
parameters: [],
jsonSchema: '',
- recipeName: '',
- global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -355,8 +338,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
- recipeName: '',
- global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -522,8 +503,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
- recipeName: '',
- global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
@@ -597,8 +576,6 @@ describe('RecipeFormFields', () => {
},
],
jsonSchema: '',
- recipeName: '',
- global: true,
} as RecipeFormData,
onSubmit: async ({ value }) => {
console.log('Form submitted:', value);
diff --git a/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts b/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
index 34255ec44fb7..2ebd9591bc57 100644
--- a/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
+++ b/ui/desktop/src/components/recipes/shared/__tests__/recipeFormSchema.test.ts
@@ -17,8 +17,6 @@ describe('recipeFormSchema', () => {
},
],
jsonSchema: '{"type": "object"}',
- recipeName: 'test_recipe',
- global: true,
};
describe('Zod Schema Validation', () => {
@@ -154,16 +152,6 @@ describe('recipeFormSchema', () => {
expect(result.success).toBe(true);
});
- it('rejects invalid JSON schema', () => {
- const invalidData = { ...validFormData, jsonSchema: 'invalid json' };
- const result = recipeFormSchema.safeParse(invalidData);
- expect(result.success).toBe(false);
- if (!result.success) {
- const jsonError = result.error.issues.find((issue) => issue.path.includes('jsonSchema'));
- expect(jsonError?.message).toBe('Invalid JSON schema format');
- }
- });
-
it('allows empty JSON schema', () => {
const validData = { ...validFormData, jsonSchema: '' };
const result = recipeFormSchema.safeParse(validData);
@@ -177,31 +165,6 @@ describe('recipeFormSchema', () => {
});
});
- describe('Recipe Name Validation', () => {
- it('allows empty recipe name', () => {
- const validData = { ...validFormData, recipeName: '' };
- const result = recipeFormSchema.safeParse(validData);
- expect(result.success).toBe(true);
- });
-
- it('allows undefined recipe name', () => {
- const validData = { ...validFormData, recipeName: undefined };
- const result = recipeFormSchema.safeParse(validData);
- expect(result.success).toBe(true);
- });
-
- it('rejects invalid recipe name characters', () => {
- // The regex /^[^<>:"/\\|?*]+$/ rejects these specific characters
- const invalidData = { ...validFormData, recipeName: 'invalid issue.path.includes('recipeName'));
- expect(nameError?.message).toContain('invalid characters');
- }
- });
- });
-
describe('Parameter Validation', () => {
it('validates parameters with all required fields', () => {
const validData = {
@@ -305,20 +268,6 @@ describe('recipeFormSchema', () => {
});
});
- describe('Global Field Validation', () => {
- it('validates global field as boolean true', () => {
- const validData = { ...validFormData, global: true };
- const result = recipeFormSchema.safeParse(validData);
- expect(result.success).toBe(true);
- });
-
- it('validates global field as boolean false', () => {
- const validData = { ...validFormData, global: false };
- const result = recipeFormSchema.safeParse(validData);
- expect(result.success).toBe(true);
- });
- });
-
describe('Multiple Validation Errors', () => {
it('handles multiple validation errors', () => {
const invalidData = {
@@ -326,7 +275,6 @@ describe('recipeFormSchema', () => {
title: 'AB', // Too short
description: 'Short', // Too short
instructions: 'Short', // Too short
- jsonSchema: 'invalid json',
};
const result = recipeFormSchema.safeParse(invalidData);
expect(result.success).toBe(false);
@@ -339,7 +287,6 @@ describe('recipeFormSchema', () => {
expect(result.error.issues.some((issue) => issue.path.includes('instructions'))).toBe(
true
);
- expect(result.error.issues.some((issue) => issue.path.includes('jsonSchema'))).toBe(true);
}
});
});
diff --git a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
index 6de1c423ef57..3eda6f8dedf6 100644
--- a/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
+++ b/ui/desktop/src/components/recipes/shared/recipeFormSchema.ts
@@ -1,5 +1,4 @@
import { z } from 'zod';
-import { validateJsonSchema } from '../../../recipe/validation';
// Zod schema for Parameter - matching API RecipeParameter type
const parameterSchema = z.object({
@@ -39,29 +38,7 @@ export const recipeFormSchema = z.object({
parameters: z.array(parameterSchema).default([]),
- jsonSchema: z
- .string()
- .optional()
- .refine((value) => {
- if (!value || !value.trim()) return true;
- try {
- const parsed = JSON.parse(value.trim());
- const validationResult = validateJsonSchema(parsed);
- return validationResult.success;
- } catch {
- return false;
- }
- }, 'Invalid JSON schema format'),
-
- recipeName: z
- .string()
- .optional()
- .refine((name) => {
- if (!name || !name.trim()) return true;
- return /^[^<>:"/\\|?*]+$/.test(name.trim());
- }, 'Recipe name contains invalid characters (< > : " / \\ | ? *)'),
-
- global: z.boolean().default(true),
+ jsonSchema: z.string().optional(),
});
export type RecipeFormData = z.infer;
diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
index 855f7b0759da..f0edaa42cf88 100644
--- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
+++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
@@ -6,7 +6,7 @@ import { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
import * as yaml from 'yaml';
import { Recipe, decodeRecipe } from '../../recipe';
-import { getStorageDirectory } from '../../recipe/recipeStorage';
+import { getStorageDirectory } from '../../recipe/recipe_management';
import ClockIcon from '../../assets/clock-icon.svg';
type FrequencyValue = 'once' | 'every' | 'daily' | 'weekly' | 'monthly';
diff --git a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
index df1a7e893eaa..e9a4c4f6c9c7 100644
--- a/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
+++ b/ui/desktop/src/components/settings/models/bottom_bar/ModelsBottomBar.tsx
@@ -1,4 +1,4 @@
-import { Sliders, ChefHat, Bot, Eye, Save } from 'lucide-react';
+import { Sliders, ChefHat, Bot, Eye } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
@@ -17,9 +17,7 @@ import { getProviderMetadata } from '../modelInterface';
import { Alert } from '../../../alerts';
import BottomMenuAlertPopover from '../../../bottom_menu/BottomMenuAlertPopover';
import { Recipe } from '../../../../recipe';
-import { generateRecipeFilename } from '../../../../recipe/recipeStorage';
import CreateEditRecipeModal from '../../../recipes/CreateEditRecipeModal';
-import SaveRecipeDialog from '../../../recipes/shared/SaveRecipeDialog';
interface ModelsBottomBarProps {
sessionId: string | null;
@@ -27,6 +25,7 @@ interface ModelsBottomBarProps {
setView: (view: View) => void;
alerts: Alert[];
recipe?: Recipe | null;
+ recipeId?: string | null;
hasMessages?: boolean; // Add prop to know if there are messages to create a recipe from
}
@@ -36,6 +35,7 @@ export default function ModelsBottomBar({
setView,
alerts,
recipe,
+ recipeId,
hasMessages = false,
}: ModelsBottomBarProps) {
const {
@@ -54,9 +54,6 @@ export default function ModelsBottomBar({
const [isLeadWorkerActive, setIsLeadWorkerActive] = useState(false);
const [providerDefaultModel, setProviderDefaultModel] = useState(null);
- // Save recipe dialog state
- const [showSaveDialog, setShowSaveDialog] = useState(false);
-
// View recipe modal state
const [showViewRecipeModal, setShowViewRecipeModal] = useState(false);
@@ -174,13 +171,6 @@ export default function ModelsBottomBar({
}
};
- // Handle save recipe - show save dialog
- const handleSaveRecipeClick = () => {
- if (recipe) {
- setShowSaveDialog(true);
- }
- };
-
return (
@@ -219,10 +209,6 @@ export default function ModelsBottomBar({
View/Edit Recipe
-
- Save Recipe
-
-
>
)}
@@ -256,15 +242,6 @@ export default function ModelsBottomBar({
) : null}
- {/* Save Recipe Dialog */}
- {showSaveDialog && recipe && (
- setShowSaveDialog(false)}
- recipe={recipe}
- />
- )}
-
{/* View Recipe Modal */}
{/* todo: we don't have the actual recipe name when in chat only in recipes list view so we generate it for now */}
{recipe && (
@@ -272,7 +249,7 @@ export default function ModelsBottomBar({
isOpen={showViewRecipeModal}
onClose={() => setShowViewRecipeModal(false)}
recipe={recipe}
- recipeName={generateRecipeFilename(recipe)}
+ recipeId={recipeId}
/>
)}
diff --git a/ui/desktop/src/hooks/useAgent.ts b/ui/desktop/src/hooks/useAgent.ts
index dc588c1fa151..6c7f0d4e0da2 100644
--- a/ui/desktop/src/hooks/useAgent.ts
+++ b/ui/desktop/src/hooks/useAgent.ts
@@ -52,7 +52,6 @@ export function useAgent(): UseAgentReturn {
const [recipeFromAppConfig, setRecipeFromAppConfig] = useState(
(window.appConfig.get('recipe') as Recipe) || null
);
-
const { getExtensions, addExtension, read } = useConfig();
const resetChat = useCallback(() => {
diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts
index e95d6130731e..5abf9f47df29 100644
--- a/ui/desktop/src/hooks/useRecipeManager.ts
+++ b/ui/desktop/src/hooks/useRecipeManager.ts
@@ -286,8 +286,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => {
});
};
+ const recipeId: string | null =
+ (window.appConfig.get('recipeId') as string | null | undefined) ?? null;
+
return {
recipe: finalRecipe,
+ recipeId,
recipeParameters,
filteredParameters,
initialPrompt,
diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts
index 5696017435dd..eaeb980e38f7 100644
--- a/ui/desktop/src/main.ts
+++ b/ui/desktop/src/main.ts
@@ -509,7 +509,8 @@ const createChat = async (
recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink
viewType?: string,
recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding.
- scheduledJobId?: string // Scheduled job ID if applicable
+ scheduledJobId?: string, // Scheduled job ID if applicable
+ recipeId?: string
) => {
// Initialize variables for process and configuration
let port = 0;
@@ -581,6 +582,7 @@ const createChat = async (
GOOSE_BASE_URL_SHARE: baseUrlShare,
GOOSE_VERSION: version,
recipe: recipe,
+ recipeId: recipeId,
}),
],
partition: 'persist:goose', // Add this line to ensure persistence
@@ -1961,18 +1963,32 @@ async function appMain() {
}
});
- ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId, recipe, viewType) => {
- if (!dir?.trim()) {
- const recentDirs = loadRecentDirs();
- dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
- }
+ ipcMain.on(
+ 'create-chat-window',
+ (_, query, dir, version, resumeSessionId, recipe, viewType, recipeId) => {
+ if (!dir?.trim()) {
+ const recentDirs = loadRecentDirs();
+ dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
+ }
- // Log the recipe for debugging
- console.log('Creating chat window with recipe:', recipe);
+ // Log the recipe for debugging
+ console.log('Creating chat window with recipe:', recipe);
- // Pass recipe as part of viewOptions when viewType is recipeEditor
- createChat(app, query, dir, version, resumeSessionId, recipe, viewType);
- });
+ // Pass recipe as part of viewOptions when viewType is recipeEditor
+ createChat(
+ app,
+ query,
+ dir,
+ version,
+ resumeSessionId,
+ recipe,
+ viewType,
+ undefined,
+ undefined,
+ recipeId
+ );
+ }
+ );
ipcMain.on('notify', (_event, data) => {
try {
diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts
index a577df3083eb..4c103a61bf83 100644
--- a/ui/desktop/src/preload.ts
+++ b/ui/desktop/src/preload.ts
@@ -53,7 +53,8 @@ type ElectronAPI = {
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
- viewType?: string
+ viewType?: string,
+ recipeId?: string
) => void;
logInfo: (txt: string) => void;
showNotification: (data: NotificationData) => void;
@@ -140,9 +141,19 @@ const electronAPI: ElectronAPI = {
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
- viewType?: string
+ viewType?: string,
+ recipeId?: string
) =>
- ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId, recipe, viewType),
+ ipcRenderer.send(
+ 'create-chat-window',
+ query,
+ dir,
+ version,
+ resumeSessionId,
+ recipe,
+ viewType,
+ recipeId
+ ),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: NotificationData) => ipcRenderer.send('notify', data),
showMessageBox: (options: MessageBoxOptions) => ipcRenderer.invoke('show-message-box', options),
diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts
deleted file mode 100644
index c012c5dc9bd1..000000000000
--- a/ui/desktop/src/recipe/recipeStorage.ts
+++ /dev/null
@@ -1,157 +0,0 @@
-import { listRecipes, RecipeManifestResponse } from '../api';
-import { Recipe } from './index';
-import * as yaml from 'yaml';
-import { validateRecipe, getValidationErrorMessages } from './validation';
-
-export interface SaveRecipeOptions {
- name: string;
- title?: string;
- global?: boolean; // true for global (~/.config/goose/recipes/), false for project-specific (.goose/recipes/)
-}
-
-export interface SavedRecipe {
- name: string;
- recipe: Recipe;
- isGlobal: boolean;
- lastModified: Date;
- isArchived?: boolean;
- filename: string; // The actual filename used
-}
-
-/**
- * Sanitize a recipe name to be safe for use as a filename
- */
-function sanitizeRecipeName(name: string): string {
- return name.replace(/[^a-zA-Z0-9-_\s]/g, '').trim();
-}
-
-/**
- * Parse a lastModified value that could be a string or Date
- */
-function parseLastModified(val: string | Date): Date {
- return val instanceof Date ? val : new Date(val);
-}
-
-/**
- * Get the storage directory path for recipes
- */
-export function getStorageDirectory(isGlobal: boolean): string {
- if (isGlobal) {
- return '~/.config/goose/recipes';
- } else {
- // For directory recipes, build absolute path using working directory
- const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
- return `${workingDir}/.goose/recipes`;
- }
-}
-
-/**
- * Get the file path for a recipe based on its name
- */
-function getRecipeFilePath(recipeName: string, isGlobal: boolean): string {
- const dir = getStorageDirectory(isGlobal);
- return `${dir}/${recipeName}.yaml`;
-}
-
-/**
- * Save recipe to file
- */
-async function saveRecipeToFile(recipe: SavedRecipe): Promise {
- const filePath = getRecipeFilePath(recipe.name, recipe.isGlobal);
-
- // Ensure directory exists
- const dirPath = getStorageDirectory(recipe.isGlobal);
- await window.electron.ensureDirectory(dirPath);
-
- // Convert to YAML and save
- const yamlContent = yaml.stringify(recipe);
- return await window.electron.writeFile(filePath, yamlContent);
-}
-/**
- * Save a recipe to a file using IPC.
- */
-export async function saveRecipe(recipe: Recipe, options: SaveRecipeOptions): Promise {
- const { name, title, global = true } = options;
-
- let sanitizedName: string;
-
- if (title) {
- recipe.title = title.trim();
- sanitizedName = generateRecipeFilename(recipe);
- if (!sanitizedName) {
- throw new Error('Invalid recipe title - cannot generate filename');
- }
- } else {
- // This branch should now be considered deprecated and will be removed once the same functionality
- // is incorporated in CreateRecipeForm
- sanitizedName = sanitizeRecipeName(name);
- if (!sanitizedName) {
- throw new Error('Invalid recipe name');
- }
- }
-
- const validationResult = validateRecipe(recipe);
- if (!validationResult.success) {
- const errorMessages = getValidationErrorMessages(validationResult.errors);
- throw new Error(`Recipe validation failed: ${errorMessages.join(', ')}`);
- }
-
- try {
- // Create saved recipe object
- const savedRecipe: SavedRecipe = {
- name: sanitizedName,
- filename: sanitizedName,
- recipe: recipe,
- isGlobal: global,
- lastModified: new Date(),
- isArchived: false,
- };
-
- // Save to file
- const success = await saveRecipeToFile(savedRecipe);
-
- if (!success) {
- throw new Error('Failed to save recipe file');
- }
-
- // Return identifier for the saved recipe
- return `${global ? 'global' : 'local'}:${sanitizedName}`;
- } catch (error) {
- throw new Error(
- `Failed to save recipe: ${error instanceof Error ? error.message : 'Unknown error'}`
- );
- }
-}
-
-export async function listSavedRecipes(): Promise {
- try {
- const listRecipeResponse = await listRecipes();
- return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
- } catch (error) {
- console.warn('Failed to list saved recipes:', error);
- return [];
- }
-}
-
-export function convertToLocaleDateString(lastModified: string): string {
- if (lastModified) {
- return parseLastModified(lastModified).toLocaleDateString();
- }
- return '';
-}
-
-/**
- * Generate a suggested filename for a recipe based on its title.
- *
- * @param recipe The recipe to generate a filename for
- * @returns A sanitized filename suitable for use as a recipe name
- */
-export function generateRecipeFilename(recipe: Recipe): string {
- const baseName = recipe.title
- .toLowerCase()
- .replace(/[^a-zA-Z0-9\s-]/g, '')
- .replace(/\s+/g, '-')
- .trim();
-
- return baseName || 'untitled-recipe';
-}
diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts
new file mode 100644
index 000000000000..35083f44ec45
--- /dev/null
+++ b/ui/desktop/src/recipe/recipe_management.ts
@@ -0,0 +1,50 @@
+import { Recipe, saveRecipe as saveRecipeApi, listRecipes, RecipeManifestResponse } from '../api';
+
+export async function saveRecipe(recipe: Recipe, recipeId?: string | null): Promise {
+ try {
+ await saveRecipeApi({
+ body: {
+ recipe,
+ id: recipeId,
+ },
+ throwOnError: true,
+ });
+ } catch (error) {
+ let error_message = 'unknown error';
+ if (typeof error === 'object' && error !== null && 'message' in error) {
+ error_message = error.message as string;
+ }
+ throw new Error(error_message);
+ }
+}
+
+export async function listSavedRecipes(): Promise {
+ try {
+ const listRecipeResponse = await listRecipes();
+ return listRecipeResponse?.data?.recipe_manifest_responses ?? [];
+ } catch (error) {
+ console.warn('Failed to list saved recipes:', error);
+ return [];
+ }
+}
+
+function parseLastModified(val: string | Date): Date {
+ return val instanceof Date ? val : new Date(val);
+}
+
+export function convertToLocaleDateString(lastModified: string): string {
+ if (lastModified) {
+ return parseLastModified(lastModified).toLocaleDateString();
+ }
+ return '';
+}
+
+export function getStorageDirectory(isGlobal: boolean): string {
+ if (isGlobal) {
+ return '~/.config/goose/recipes';
+ } else {
+ // For directory recipes, build absolute path using working directory
+ const workingDir = window.appConfig.get('GOOSE_WORKING_DIR') as string;
+ return `${workingDir}/.goose/recipes`;
+ }
+}
diff --git a/ui/desktop/src/recipe/validation.test.ts b/ui/desktop/src/recipe/validation.test.ts
index 1d6824c654c3..b56c322b2294 100644
--- a/ui/desktop/src/recipe/validation.test.ts
+++ b/ui/desktop/src/recipe/validation.test.ts
@@ -1,379 +1,7 @@
import { describe, it, expect } from 'vitest';
-import {
- validateRecipe,
- validateJsonSchema,
- getValidationErrorMessages,
- getRecipeJsonSchema,
-} from './validation';
-import type { Recipe } from '../api/types.gen';
+import { getRecipeJsonSchema } from './validation';
describe('Recipe Validation', () => {
- const validRecipe: Recipe = {
- version: '1.0.0',
- title: 'Test Recipe',
- description: 'A test recipe for validation',
- instructions: 'Do something useful',
- activities: ['Test activity 1', 'Test activity 2'],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- display_name: 'Developer',
- description: 'Developer',
- timeout: 300,
- bundled: true,
- },
- ],
- };
-
- const validRecipeWithPrompt: Recipe = {
- version: '1.0.0',
- title: 'Prompt Recipe',
- description: 'A recipe using prompt instead of instructions',
- prompt: 'You are a helpful assistant',
- activities: ['Help users'],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- description: 'Developer',
- },
- ],
- };
-
- const validRecipeWithParameters: Recipe = {
- version: '1.0.0',
- title: 'Parameterized Recipe',
- description: 'A recipe with parameters',
- instructions: 'Process the file at {{ file_path }}',
- parameters: [
- {
- key: 'file_path',
- input_type: 'string',
- requirement: 'required',
- description: 'Path to the file to process',
- },
- ],
- activities: ['Process file'],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- description: 'developer',
- },
- ],
- };
-
- const validRecipeWithAuthor: Recipe = {
- version: '1.0.0',
- title: 'Authored Recipe',
- author: {
- contact: 'test@example.com',
- },
- description: 'A recipe with author information',
- instructions: 'Do something',
- activities: ['Activity'],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- description: 'developer',
- },
- ],
- };
-
- describe('validateRecipe', () => {
- describe('valid recipes', () => {
- it('validates a basic valid recipe', () => {
- const result = validateRecipe(validRecipe);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toEqual(validRecipe);
- });
-
- it('validates a recipe with prompt instead of instructions', () => {
- const result = validateRecipe(validRecipeWithPrompt);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toEqual(validRecipeWithPrompt);
- });
-
- it('validates a recipe with parameters', () => {
- const result = validateRecipe(validRecipeWithParameters);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toEqual(validRecipeWithParameters);
- });
-
- it('validates a recipe with author information', () => {
- const result = validateRecipe(validRecipeWithAuthor);
- if (!result.success) {
- console.log('Author validation errors:', result.errors);
- }
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('validates a recipe with minimal required fields', () => {
- const minimalRecipe = {
- version: '1.0.0',
- title: 'Minimal',
- description: 'Minimal recipe',
- instructions: 'Do something',
- activities: ['Activity'],
- extensions: [],
- };
-
- const result = validateRecipe(minimalRecipe);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
- });
-
- describe('invalid recipes', () => {
- it('rejects recipe without title', () => {
- const invalidRecipe = {
- ...validRecipe,
- title: undefined,
- };
-
- const result = validateRecipe(invalidRecipe);
- expect(result.success).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- expect(result.data).toBeUndefined();
- });
-
- it('rejects recipe without description', () => {
- const invalidRecipe = {
- ...validRecipe,
- description: undefined,
- };
-
- const result = validateRecipe(invalidRecipe);
- expect(result.success).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('allows recipe without version (version is optional)', () => {
- const recipeWithoutVersion = {
- ...validRecipe,
- version: undefined,
- };
-
- const result = validateRecipe(recipeWithoutVersion);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it('rejects recipe without instructions or prompt', () => {
- const invalidRecipe = {
- ...validRecipe,
- instructions: undefined,
- prompt: undefined,
- };
-
- const result = validateRecipe(invalidRecipe);
- expect(result.success).toBe(false);
- expect(result.errors).toContain('Either instructions or prompt must be provided');
- });
-
- it('validates recipe with minimal extension structure', () => {
- const recipeWithMinimalExtension = {
- ...validRecipe,
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- description: 'description',
- },
- ],
- };
-
- const result = validateRecipe(recipeWithMinimalExtension);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- });
-
- it('validates recipe with incomplete parameter structure', () => {
- const recipeWithIncompleteParam = {
- ...validRecipe,
- parameters: [
- {
- key: 'test',
- },
- ],
- };
-
- const result = validateRecipe(recipeWithIncompleteParam);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('rejects non-object input', () => {
- const result = validateRecipe('not an object');
- expect(result.success).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('rejects null input', () => {
- const result = validateRecipe(null);
- expect(result.success).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
-
- it('rejects undefined input', () => {
- const result = validateRecipe(undefined);
- expect(result.success).toBe(false);
- expect(result.errors.length).toBeGreaterThan(0);
- });
- });
-
- describe('edge cases', () => {
- it('handles empty arrays gracefully', () => {
- const recipeWithEmptyArrays = {
- ...validRecipe,
- activities: [],
- extensions: [],
- parameters: [],
- };
-
- const result = validateRecipe(recipeWithEmptyArrays);
- expect(result.success).toBe(true);
- });
-
- it('handles extra properties', () => {
- const recipeWithExtra = {
- ...validRecipe,
- extraField: 'should be ignored or handled gracefully',
- };
-
- const result = validateRecipe(recipeWithExtra);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('handles very long strings', () => {
- const longString = 'a'.repeat(10000);
- const recipeWithLongStrings = {
- ...validRecipe,
- title: longString,
- description: longString,
- instructions: longString,
- };
-
- const result = validateRecipe(recipeWithLongStrings);
- expect(typeof result.success).toBe('boolean');
- });
- });
- });
-
- describe('validateJsonSchema', () => {
- describe('valid JSON schemas', () => {
- it('validates a simple JSON schema', () => {
- const schema = {
- type: 'object',
- properties: {
- name: { type: 'string' },
- age: { type: 'number' },
- },
- required: ['name'],
- };
-
- const result = validateJsonSchema(schema);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toEqual(schema);
- });
-
- it('validates null schema', () => {
- const result = validateJsonSchema(null);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toBe(null);
- });
-
- it('validates undefined schema', () => {
- const result = validateJsonSchema(undefined);
- expect(result.success).toBe(true);
- expect(result.errors).toHaveLength(0);
- expect(result.data).toBe(undefined);
- });
-
- it('validates complex JSON schema', () => {
- const schema = {
- $schema: 'http://json-schema.org/draft-07/schema#',
- type: 'object',
- properties: {
- users: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- id: { type: 'number' },
- profile: {
- type: 'object',
- properties: {
- name: { type: 'string' },
- email: { type: 'string' },
- },
- },
- },
- },
- },
- },
- };
-
- const result = validateJsonSchema(schema);
- expect(result.success).toBe(true);
- expect(result.data).toEqual(schema);
- });
- });
-
- describe('invalid JSON schemas', () => {
- it('rejects string input', () => {
- const result = validateJsonSchema('not an object');
- expect(result.success).toBe(false);
- expect(result.errors).toContain('JSON Schema must be an object');
- });
-
- it('rejects number input', () => {
- const result = validateJsonSchema(42);
- expect(result.success).toBe(false);
- expect(result.errors).toContain('JSON Schema must be an object');
- });
-
- it('rejects boolean input', () => {
- const result = validateJsonSchema(true);
- expect(result.success).toBe(false);
- expect(result.errors).toContain('JSON Schema must be an object');
- });
-
- it('validates array input as valid JSON schema', () => {
- const result = validateJsonSchema(['not', 'an', 'object']);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
- });
- });
-
- describe('helper functions', () => {
- describe('getValidationErrorMessages', () => {
- it('returns the same array of error messages', () => {
- const errors = ['title: Required', 'description: Required', 'Invalid format'];
- const messages = getValidationErrorMessages(errors);
- expect(messages).toEqual(errors);
- expect(messages).toHaveLength(3);
- });
-
- it('handles empty array', () => {
- const errors: string[] = [];
- const messages = getValidationErrorMessages(errors);
- expect(messages).toHaveLength(0);
- expect(messages).toEqual([]);
- });
- });
- });
-
describe('getRecipeJsonSchema', () => {
it('returns a valid JSON schema object', () => {
const schema = getRecipeJsonSchema();
@@ -401,162 +29,4 @@ describe('Recipe Validation', () => {
expect(schema1).toEqual(schema2);
});
});
-
- describe('error handling and edge cases', () => {
- it('handles validation errors gracefully', () => {
- // Test with malformed data that might cause validation to throw
- const malformedData = {
- version: { not: 'a string' },
- title: ['not', 'a', 'string'],
- description: 123,
- instructions: null,
- activities: 'not an array',
- extensions: 'not an array',
- };
-
- const result = validateRecipe(malformedData);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('handles circular references gracefully', () => {
- const circularObj: Record = { title: 'Test' };
- (circularObj as Record).self = circularObj;
-
- const result = validateRecipe(circularObj);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('handles very deep nested objects', () => {
- let deepObj: Record = {
- version: '1.0.0',
- title: 'Deep',
- description: 'Test',
- };
- let current: Record = deepObj;
-
- // Create a deeply nested structure
- for (let i = 0; i < 100; i++) {
- const nested = { level: i };
- current.nested = nested;
- current = nested as Record;
- }
-
- const result = validateRecipe(deepObj);
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
- });
-
- describe('real-world recipe examples', () => {
- it('validates readme-bot style recipe', () => {
- const readmeBotRecipe = {
- version: '1.0.0',
- title: 'Readme Bot',
- author: {
- contact: 'DOsinga',
- },
- description: 'Generates or updates a readme',
- instructions: 'You are a documentation expert',
- activities: [
- 'Scan project directory for documentation context',
- 'Generate a new README draft',
- 'Compare new draft with existing README.md',
- ],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- display_name: 'Developer',
- timeout: 300,
- bundled: true,
- },
- ],
- prompt: "Here's what to do step by step: 1. The current folder is a software project...",
- };
-
- const result = validateRecipe(readmeBotRecipe);
- if (!result.success) {
- console.log('ReadmeBot validation errors:', result.errors);
- }
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('validates lint-my-code style recipe with parameters', () => {
- const lintRecipe = {
- version: '1.0.0',
- title: 'Lint My Code',
- author: {
- contact: 'iandouglas',
- },
- description:
- 'Analyzes code files for syntax and layout issues using available linting tools',
- instructions:
- 'You are a code quality expert that helps identify syntax and layout issues in code files',
- activities: [
- 'Detect file type and programming language',
- 'Check for available linting tools in the project',
- 'Run appropriate linters for syntax and layout checking',
- 'Provide recommendations if no linters are found',
- ],
- parameters: [
- {
- key: 'file_path',
- input_type: 'string',
- requirement: 'required',
- description: 'Path to the file you want to lint',
- },
- ],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- display_name: 'Developer',
- timeout: 300,
- bundled: true,
- },
- ],
- prompt:
- 'I need you to lint the file at {{ file_path }} for syntax and layout issues only...',
- };
-
- const result = validateRecipe(lintRecipe);
- if (!result.success) {
- console.log('LintRecipe validation errors:', result.errors);
- }
- expect(typeof result.success).toBe('boolean');
- expect(Array.isArray(result.errors)).toBe(true);
- });
-
- it('validates 404Portfolio style recipe with multiple extensions', () => {
- const portfolioRecipe = {
- version: '1.0.0',
- title: '404Portfolio',
- description: 'Create personalized, creative 404 pages using public profile data',
- instructions: 'Create an engaging 404 error page that tells a creative story...',
- activities: [
- 'Build error page from GitHub repos',
- 'Generate error page from dev.to blog posts',
- 'Create a 404 page featuring Bluesky bio',
- ],
- extensions: [
- {
- type: 'builtin',
- name: 'developer',
- description: 'developer',
- },
- {
- type: 'builtin',
- name: 'computercontroller',
- description: 'computercontroller',
- },
- ],
- };
-
- const result = validateRecipe(portfolioRecipe);
- expect(result.success).toBe(true);
- });
- });
});
diff --git a/ui/desktop/src/recipe/validation.ts b/ui/desktop/src/recipe/validation.ts
index 065e2c2f3e41..431cb4ce5b03 100644
--- a/ui/desktop/src/recipe/validation.ts
+++ b/ui/desktop/src/recipe/validation.ts
@@ -1,6 +1,3 @@
-import { z } from 'zod';
-import type { Recipe } from '../api/types.gen';
-
/**
* OpenAPI-based validation utilities for Recipe objects.
*
@@ -115,285 +112,6 @@ function resolveRefs(
return schema;
}
-export type RecipeValidationResult = {
- success: boolean;
- errors: string[];
- data?: Recipe | unknown;
-};
-
-/**
- * Converts an OpenAPI schema to a Zod schema dynamically
- */
-function openApiSchemaToZod(schema: Record): z.ZodTypeAny {
- if (!schema) {
- return z.any();
- }
-
- // Handle different schema types
- switch (schema.type) {
- case 'string': {
- let stringSchema = z.string();
- if (typeof schema.minLength === 'number') {
- stringSchema = stringSchema.min(schema.minLength);
- }
- if (typeof schema.maxLength === 'number') {
- stringSchema = stringSchema.max(schema.maxLength);
- }
- if (Array.isArray(schema.enum)) {
- return z.enum(schema.enum as [string, ...string[]]);
- }
- if (schema.format === 'date-time') {
- stringSchema = stringSchema.datetime();
- }
- if (typeof schema.pattern === 'string') {
- stringSchema = stringSchema.regex(new RegExp(schema.pattern));
- }
- return schema.nullable ? stringSchema.nullable() : stringSchema;
- }
-
- case 'number':
- case 'integer': {
- let numberSchema = schema.type === 'integer' ? z.number().int() : z.number();
- if (typeof schema.minimum === 'number') {
- numberSchema = numberSchema.min(schema.minimum);
- }
- if (typeof schema.maximum === 'number') {
- numberSchema = numberSchema.max(schema.maximum);
- }
- return schema.nullable ? numberSchema.nullable() : numberSchema;
- }
-
- case 'boolean':
- return schema.nullable ? z.boolean().nullable() : z.boolean();
-
- case 'array': {
- const itemSchema = schema.items
- ? openApiSchemaToZod(schema.items as Record)
- : z.any();
- let arraySchema = z.array(itemSchema);
- if (typeof schema.minItems === 'number') {
- arraySchema = arraySchema.min(schema.minItems);
- }
- if (typeof schema.maxItems === 'number') {
- arraySchema = arraySchema.max(schema.maxItems);
- }
- return schema.nullable ? arraySchema.nullable() : arraySchema;
- }
-
- case 'object':
- if (schema.properties && typeof schema.properties === 'object') {
- const shape: Record = {};
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
- shape[propName] = openApiSchemaToZod(propSchema as Record);
- }
-
- // Make optional properties optional based on required array
- const optionalShape: Record = {};
- const requiredFields =
- schema.required && Array.isArray(schema.required) ? schema.required : [];
-
- for (const [propName, zodSchema] of Object.entries(shape)) {
- if (requiredFields.includes(propName)) {
- optionalShape[propName] = zodSchema;
- } else {
- optionalShape[propName] = zodSchema.optional();
- }
- }
-
- let objectSchema = z.object(optionalShape);
-
- if (schema.additionalProperties === true) {
- return schema.nullable
- ? objectSchema.passthrough().nullable()
- : objectSchema.passthrough();
- } else if (schema.additionalProperties === false) {
- return schema.nullable ? objectSchema.strict().nullable() : objectSchema.strict();
- }
-
- return schema.nullable ? objectSchema.nullable() : objectSchema;
- }
- return schema.nullable ? z.record(z.any()).nullable() : z.record(z.any());
-
- default:
- // Handle $ref, allOf, oneOf, anyOf, etc.
- if (typeof schema.$ref === 'string') {
- // Resolve the $ref and convert the resolved schema to Zod
- const resolvedSchema = resolveRefs(schema, openApiSpec as Record);
- // If resolution changed the schema, convert the resolved version
- if (resolvedSchema !== schema) {
- return openApiSchemaToZod(resolvedSchema);
- }
- // If resolution failed, fall back to z.any()
- return z.any();
- }
-
- if (Array.isArray(schema.allOf)) {
- // Intersection of all schemas
- return schema.allOf.reduce((acc: z.ZodTypeAny, subSchema: unknown) => {
- return acc.and(openApiSchemaToZod(subSchema as Record));
- }, z.any());
- }
-
- if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) {
- // Union of schemas
- const schemaArray = (schema.oneOf || schema.anyOf) as unknown[];
- const schemas = schemaArray.map((subSchema: unknown) =>
- openApiSchemaToZod(subSchema as Record)
- );
- return z.union(schemas as [z.ZodTypeAny, z.ZodTypeAny, ...z.ZodTypeAny[]]);
- }
-
- return z.any();
- }
-}
-
-/**
- * Validates a value against an OpenAPI schema using Zod
- */
-function validateAgainstSchema(value: unknown, schema: Record): string[] {
- if (!schema) {
- return ['Schema not found'];
- }
-
- try {
- // Resolve $refs in the schema before converting to Zod
- const resolvedSchema = resolveRefs(schema, openApiSpec as Record);
- const zodSchema = openApiSchemaToZod(resolvedSchema);
- const result = zodSchema.safeParse(value);
-
- if (result.success) {
- return [];
- } else {
- return result.error.errors.map((err) => {
- const path = err.path.length > 0 ? `${err.path.join('.')}: ` : '';
- return `${path}${err.message}`;
- });
- }
- } catch (error) {
- return [`Schema conversion error: ${error instanceof Error ? error.message : 'Unknown error'}`];
- }
-}
-
-/**
- * Validates a recipe object against the OpenAPI-derived schema.
- * This provides structural validation that automatically stays in sync
- * with the backend's OpenAPI specification.
- */
-export function validateRecipe(recipe: unknown): RecipeValidationResult {
- try {
- const schema = getRecipeSchema();
- if (!schema) {
- return {
- success: false,
- errors: ['Recipe schema not found in OpenAPI specification'],
- };
- }
-
- const errors = validateAgainstSchema(recipe, schema as Record);
-
- // Additional business logic validation
- if (typeof recipe === 'object' && recipe !== null) {
- const recipeObj = recipe as Partial;
- if (!recipeObj.instructions && !recipeObj.prompt) {
- errors.push('Either instructions or prompt must be provided');
- }
- }
-
- if (errors.length === 0) {
- return {
- success: true,
- errors: [],
- data: recipe as Recipe,
- };
- } else {
- return {
- success: false,
- errors,
- data: undefined,
- };
- }
- } catch (error) {
- return {
- success: false,
- errors: [`Validation error: ${error instanceof Error ? error.message : 'Unknown error'}`],
- data: undefined,
- };
- }
-}
-
-/**
- * JSON schema validation for the response.json_schema field.
- * Uses basic structural validation instead of AJV to avoid CSP eval security issues.
- */
-export function validateJsonSchema(schema: unknown): RecipeValidationResult {
- try {
- // Allow null/undefined schemas
- if (schema === null || schema === undefined) {
- return { success: true, errors: [], data: schema as unknown };
- }
-
- if (typeof schema !== 'object') {
- return {
- success: false,
- errors: ['JSON Schema must be an object'],
- data: undefined,
- };
- }
-
- const schemaObj = schema as Record;
- const errors: string[] = [];
-
- // Check for valid JSON Schema structure
- if (schemaObj.type && typeof schemaObj.type !== 'string' && !Array.isArray(schemaObj.type)) {
- errors.push('Invalid type field: must be a string or array');
- }
-
- // Check for valid properties structure if it exists
- if (schemaObj.properties && typeof schemaObj.properties !== 'object') {
- errors.push('Invalid properties field: must be an object');
- }
-
- // Check for valid required array if it exists
- if (schemaObj.required && !Array.isArray(schemaObj.required)) {
- errors.push('Invalid required field: must be an array');
- }
-
- // Check for valid items structure if it exists (for array types)
- if (schemaObj.items && typeof schemaObj.items !== 'object' && !Array.isArray(schemaObj.items)) {
- errors.push('Invalid items field: must be an object or array');
- }
-
- if (errors.length > 0) {
- return {
- success: false,
- errors: errors.map((err) => `Invalid JSON Schema: ${err}`),
- data: undefined,
- };
- }
-
- return {
- success: true,
- errors: [],
- data: schema as unknown,
- };
- } catch (error) {
- return {
- success: false,
- errors: [
- `JSON Schema validation error: ${error instanceof Error ? error.message : 'Unknown error'}`,
- ],
- data: undefined,
- };
- }
-}
-
-/**
- * Helper function to format validation error messages
- */
-export function getValidationErrorMessages(errors: string[]): string[] {
- return errors;
-}
-
/**
* Returns a JSON schema representation derived directly from the OpenAPI specification.
* This schema is used for documentation in form help text.