From 61e8aefb67dfe52e29fb28ecb684ff881e5c9d37 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Tue, 26 Aug 2025 23:05:51 +1000 Subject: [PATCH 01/18] added list recipe route --- crates/goose-server/src/routes/recipe.rs | 31 ++++++++++++++++++++++++ crates/goose/src/recipe/list_recipes.rs | 6 +++++ crates/goose/src/recipe/mod.rs | 1 + 3 files changed, 38 insertions(+) create mode 100644 crates/goose/src/recipe/list_recipes.rs diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index ad463aefed4d..c40a1a3cf9f0 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -66,6 +66,16 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ListRecipeRequest { + recipe_dir: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ListRecipeResponse { + recipes: Vec +} + #[utoipa::path( post, path = "/recipes/create", @@ -209,12 +219,33 @@ async fn scan_recipe( })) } +#[utoipa::path( + get, + path = "/recipes/list", + params( + ListRecipeRequest + ), + responses( + (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse), + ), + tag = "Recipe Management" +)] +async fn list_recipes( + Query(request): Query, +) -> Result, StatusCode> { + Ok(Json(ListRecipeResponse { + recipes: vec![], + })) +} + + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) .route("/recipes/encode", post(encode_recipe)) .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) + .route("/recipes/list", get(list_recipes)) .with_state(state) } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs new file mode 100644 index 000000000000..c3683b11bc10 --- /dev/null +++ b/crates/goose/src/recipe/list_recipes.rs @@ -0,0 +1,6 @@ +use anyhow::Result; + +pub fn list_recipes(recipe_dir: &str) -> Result<()> { + // read recipes from recipe_dir + Ok(()) +} \ No newline at end of file diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index f3ad0c25566c..19284b3208f0 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -13,6 +13,7 @@ use utoipa::ToSchema; pub mod build_recipe; pub mod read_recipe_file_content; pub mod template_recipe; +pub mod list_recipes; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; From ed77066dd56e0d23285af819db5194e7d3e4169e Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Wed, 27 Aug 2025 08:35:44 +1000 Subject: [PATCH 02/18] list_recipe wip --- crates/goose/src/recipe/list_recipes.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs index c3683b11bc10..3bc6c81fcd51 100644 --- a/crates/goose/src/recipe/list_recipes.rs +++ b/crates/goose/src/recipe/list_recipes.rs @@ -1,6 +1,16 @@ +use std::path::PathBuf; + use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; + +use crate::config::APP_STRATEGY; -pub fn list_recipes(recipe_dir: &str) -> Result<()> { - // read recipes from recipe_dir +pub fn list_recipes() -> Result<()> { + let current_dir = std::env::current_dir()?; + let local_recipe_path = current_dir.join(".goose/recipes"); + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) + .map(|strategy| strategy.in_config_dir("recipes")) + .unwrap_or_else(|_| PathBuf::from("~/.config/goose/recipes")); + Ok(()) } \ No newline at end of file From c7035161ca0c3c588bcf391f4ed94e753fca40eb Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 29 Aug 2025 09:32:30 +1000 Subject: [PATCH 03/18] added list recipe endpoints --- crates/goose-server/src/openapi.rs | 6 +++- crates/goose-server/src/routes/recipe.rs | 19 +++++++++--- crates/goose/src/recipe/list_recipes.rs | 36 ++++++++++++++++++++-- crates/goose/src/recipe/mod.rs | 1 + crates/goose/src/recipe/recipe_manifest.rs | 26 ++++++++++++++++ 5 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 crates/goose/src/recipe/recipe_manifest.rs diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 0e9265a6521c..216f8209f900 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -393,7 +393,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::create_recipe, super::routes::recipe::encode_recipe, super::routes::recipe::decode_recipe, - super::routes::recipe::scan_recipe + super::routes::recipe::scan_recipe, + super::routes::recipe::list_recipes, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -463,6 +464,8 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::DecodeRecipeResponse, super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, + super::routes::recipe::ListRecipeRequest, + super::routes::recipe::ListRecipeResponse, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, @@ -471,6 +474,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { goose::recipe::RecipeParameterRequirement, goose::recipe::Response, goose::recipe::SubRecipe, + goose::recipe::recipe_manifest::RecipeManifest, goose::agents::types::RetryConfig, goose::agents::types::SuccessCheck, super::routes::agent::AddSubRecipesRequest, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index c40a1a3cf9f0..89b00fcca0db 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,12 +1,18 @@ use std::sync::Arc; +use axum::extract::Query; +use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; +use goose::recipe::list_recipes::list_sorted_recipe_manifests; +use goose::recipe::recipe_manifest::RecipeManifest; use goose::recipe::Recipe; use goose::recipe_deeplink; +use http::HeaderMap; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use crate::routes::utils::verify_secret_key; use crate::state::AppState; #[derive(Debug, Deserialize, ToSchema)] @@ -66,14 +72,14 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] pub struct ListRecipeRequest { - recipe_dir: String, + include_archived: bool, } #[derive(Debug, Serialize, ToSchema)] pub struct ListRecipeResponse { - recipes: Vec + recipe_manifests: Vec } #[utoipa::path( @@ -227,14 +233,19 @@ async fn scan_recipe( ), responses( (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") ), tag = "Recipe Management" )] async fn list_recipes( + State(state): State>, + headers: HeaderMap, Query(request): Query, ) -> Result, StatusCode> { + verify_secret_key(&headers, &state)?; Ok(Json(ListRecipeResponse { - recipes: vec![], + recipe_manifests: list_sorted_recipe_manifests(request.include_archived).unwrap(), })) } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs index 3bc6c81fcd51..932f6e912e1e 100644 --- a/crates/goose/src/recipe/list_recipes.rs +++ b/crates/goose/src/recipe/list_recipes.rs @@ -1,16 +1,48 @@ +use std::fs; use std::path::PathBuf; use anyhow::Result; use etcetera::{choose_app_strategy, AppStrategy}; use crate::config::APP_STRATEGY; +use crate::recipe::recipe_manifest::RecipeManifest; -pub fn list_recipes() -> Result<()> { +fn load_recipes_from_path(path: &PathBuf) -> Result> { + let mut recipe_manifests = Vec::new(); + if path.exists() { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + if path.extension() == Some("yaml".as_ref()) { + if let Ok(recipe_manifest) = RecipeManifest::from_yaml_file(&path) { + recipe_manifests.push(recipe_manifest); + } + } + } + } + Ok(recipe_manifests) +} + +fn get_all_recipes_manifests() -> Result> { let current_dir = std::env::current_dir()?; let local_recipe_path = current_dir.join(".goose/recipes"); + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) .map(|strategy| strategy.in_config_dir("recipes")) .unwrap_or_else(|_| PathBuf::from("~/.config/goose/recipes")); - Ok(()) + let mut recipe_manifests = Vec::new(); + + recipe_manifests.extend(load_recipes_from_path(&local_recipe_path)?); + recipe_manifests.extend(load_recipes_from_path(&global_recipe_path)?); + + Ok(recipe_manifests) +} + +pub fn list_sorted_recipe_manifests(include_archived: bool) -> Result> { + let mut recipe_manifests = get_all_recipes_manifests()?; + if !include_archived { + recipe_manifests.retain(|manifest| !manifest.is_archived); + } + recipe_manifests.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + Ok(recipe_manifests) } \ No newline at end of file diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 19284b3208f0..47f1ec84aad9 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -14,6 +14,7 @@ pub mod build_recipe; pub mod read_recipe_file_content; pub mod template_recipe; pub mod list_recipes; +pub mod recipe_manifest; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs new file mode 100644 index 000000000000..c489caf1cca1 --- /dev/null +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use std::{fs, path::Path}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use crate::recipe::Recipe; + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct RecipeManifest { + pub name: String, + pub recipes: Recipe, + #[serde(rename = "isGlobal")] + pub is_global: bool, + #[serde(rename = "lastModified")] + pub last_modified: String, + #[serde(rename = "isArchived")] + pub is_archived: bool, +} + +impl RecipeManifest { + 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 manifest = serde_yaml::from_str::(&content).map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; + Ok(manifest) + } +} From 4433b92ab9524da3b2ac766f36454897ab0991d4 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 29 Aug 2025 09:32:44 +1000 Subject: [PATCH 04/18] generate open api --- ui/desktop/openapi.json | 88 +++++++++++++++++++++++++++++++++ ui/desktop/src/api/sdk.gen.ts | 9 +++- ui/desktop/src/api/types.gen.ts | 45 +++++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 61542bf46a31..d3d0b1fe4bba 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -895,6 +895,42 @@ } } }, + "/recipes/list": { + "get": { + "tags": [ + "Recipe Management" + ], + "operationId": "list_recipes", + "parameters": [ + { + "name": "include_archived", + "in": "query", + "required": true, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Get recipe list successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListRecipeResponse" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/scan": { "post": { "tags": [ @@ -2252,6 +2288,31 @@ } } }, + "ListRecipeRequest": { + "type": "object", + "required": [ + "include_archived" + ], + "properties": { + "include_archived": { + "type": "boolean" + } + } + }, + "ListRecipeResponse": { + "type": "object", + "required": [ + "recipe_manifests" + ], + "properties": { + "recipe_manifests": { + "type": "array", + "items": { + "$ref": "#/components/schemas/RecipeManifest" + } + } + } + }, "ListSchedulesResponse": { "type": "object", "required": [ @@ -2799,6 +2860,33 @@ } } }, + "RecipeManifest": { + "type": "object", + "required": [ + "name", + "recipes", + "isGlobal", + "lastModified", + "isArchived" + ], + "properties": { + "isArchived": { + "type": "boolean" + }, + "isGlobal": { + "type": "boolean" + }, + "lastModified": { + "type": "string" + }, + "name": { + "type": "string" + }, + "recipes": { + "$ref": "#/components/schemas/Recipe" + } + } + }, "RecipeParameter": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index a36b1ce97c9f..eac735833ac1 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -270,6 +270,13 @@ export const encodeRecipe = (options: Opti }); }; +export const listRecipes = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/recipes/list', + ...options + }); +}; + export const scanRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/scan', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 76a9e3fcd13b..9c6e54fdd988 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -324,6 +324,14 @@ export type KillJobResponse = { message: string; }; +export type ListRecipeRequest = { + include_archived: boolean; +}; + +export type ListRecipeResponse = { + recipe_manifests: Array; +}; + export type ListSchedulesResponse = { jobs: Array; }; @@ -534,6 +542,14 @@ export type Recipe = { version?: string; }; +export type RecipeManifest = { + isArchived: boolean; + isGlobal: boolean; + lastModified: string; + name: string; + recipes: Recipe; +}; + export type RecipeParameter = { default?: string | null; description: string; @@ -1547,6 +1563,35 @@ export type EncodeRecipeResponses = { export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResponses]; +export type ListRecipesData = { + body?: never; + path?: never; + query: { + include_archived: boolean; + }; + url: '/recipes/list'; +}; + +export type ListRecipesErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ListRecipesResponses = { + /** + * Get recipe list successfully + */ + 200: ListRecipeResponse; +}; + +export type ListRecipesResponse = ListRecipesResponses[keyof ListRecipesResponses]; + export type ScanRecipeData = { body: ScanRecipeRequest; path?: never; From 429d3abc3e3078990049eacf0ec3f11b4ec0788a Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 29 Aug 2025 09:53:08 +1000 Subject: [PATCH 05/18] fixed the attribute name --- crates/goose/src/recipe/recipe_manifest.rs | 2 +- ui/desktop/openapi.json | 4 ++-- ui/desktop/src/api/types.gen.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs index c489caf1cca1..c2beacb0ab98 100644 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -8,7 +8,7 @@ use crate::recipe::Recipe; #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct RecipeManifest { pub name: String, - pub recipes: Recipe, + pub recipe: Recipe, #[serde(rename = "isGlobal")] pub is_global: bool, #[serde(rename = "lastModified")] diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index d3d0b1fe4bba..ad7360418bf4 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2864,7 +2864,7 @@ "type": "object", "required": [ "name", - "recipes", + "recipe", "isGlobal", "lastModified", "isArchived" @@ -2882,7 +2882,7 @@ "name": { "type": "string" }, - "recipes": { + "recipe": { "$ref": "#/components/schemas/Recipe" } } diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 9c6e54fdd988..52a154339efd 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -547,7 +547,7 @@ export type RecipeManifest = { isGlobal: boolean; lastModified: string; name: string; - recipes: Recipe; + recipe: Recipe; }; export type RecipeParameter = { From 8043c08347cd2d7840d5a04ebf79453aae2b965d Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 29 Aug 2025 11:03:07 +1000 Subject: [PATCH 06/18] update ui to use api to list recipes. todo: load recipe, and remove showing goosehints, etc on RecipesView --- ui/desktop/src/components/RecipesView.tsx | 84 +++++++---------------- ui/desktop/src/recipe/recipeStorage.ts | 59 ++-------------- 2 files changed, 30 insertions(+), 113 deletions(-) diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index a13c0e00db2d..e4fd4bfc0929 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from 'react'; import { listSavedRecipes, archiveRecipe, - SavedRecipe, saveRecipe, generateRecipeFilename, } from '../recipe/recipeStorage'; @@ -24,6 +23,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; +import { RecipeManifest } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -31,11 +31,11 @@ interface RecipesViewProps { // @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { - const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); - const [selectedRecipe, setSelectedRecipe] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); const [showPreview, setShowPreview] = useState(false); const [showContent, setShowContent] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); @@ -99,8 +99,8 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { setShowSkeleton(true); setShowContent(false); setError(null); - const recipes = await listSavedRecipes(); - setSavedRecipes(recipes); + const recipeManifests = await listSavedRecipes(); + setSavedRecipes(recipeManifests); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load recipes'); console.error('Failed to load saved recipes:', err); @@ -109,7 +109,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleLoadRecipe = async (savedRecipe: SavedRecipe) => { + const handleLoadRecipe = async (recipeManifest: RecipeManifest) => { try { // onLoadRecipe is not working for loading recipes. It looks correct // but the instructions are not flowing through to the server. @@ -125,7 +125,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { undefined, // dir undefined, // version undefined, // resumeSessionId - savedRecipe.recipe, // recipe config + recipeManifest.recipe, // recipe config undefined // view type ); // } @@ -135,14 +135,14 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleDeleteRecipe = async (savedRecipe: SavedRecipe) => { + const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => { // TODO: Use Electron's dialog API for confirmation const result = await window.electron.showMessageBox({ type: 'warning', buttons: ['Cancel', 'Delete'], defaultId: 0, title: 'Delete Recipe', - message: `Are you sure you want to delete "${savedRecipe.name}"?`, + message: `Are you sure you want to delete "${recipeManifest.name}"?`, detail: 'Deleted recipes can be restored later.', }); @@ -151,7 +151,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await archiveRecipe(savedRecipe.name, savedRecipe.isGlobal); + await archiveRecipe(recipeManifest.name, recipeManifest.isGlobal); // Reload the recipes list await loadSavedRecipes(); } catch (err) { @@ -160,13 +160,13 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handlePreviewRecipe = async (savedRecipe: SavedRecipe) => { - setSelectedRecipe(savedRecipe); + const handlePreviewRecipe = async (recipeManifest: RecipeManifest) => { + setSelectedRecipe(recipeManifest); setShowPreview(true); // Generate deeplink for preview try { - const deeplink = await generateDeepLink(savedRecipe.recipe); + const deeplink = await generateDeepLink(recipeManifest.recipe); setPreviewDeeplink(deeplink); } catch (error) { console.error('Failed to generate deeplink for preview:', error); @@ -373,24 +373,24 @@ Parameters you can use: }; // Render a recipe item - const RecipeItem = ({ savedRecipe }: { savedRecipe: SavedRecipe }) => ( + const RecipeItem = ({ recipeManifest }: { recipeManifest: RecipeManifest }) => (
-

{savedRecipe.recipe.title}

- {savedRecipe.isGlobal ? ( +

{recipeManifest.recipe.title}

+ {recipeManifest.isGlobal ? ( ) : ( )}

- {savedRecipe.recipe.description} + {recipeManifest.recipe.description}

- {savedRecipe.lastModified.toLocaleDateString()} + {recipeManifest.lastModified}
@@ -398,7 +398,7 @@ Parameters you can use:
)} - - {selectedRecipe.recipe.goosehints && ( -
-

Goose Hints

-
-
-                      {selectedRecipe.recipe.goosehints}
-                    
-
-
- )} - {selectedRecipe.recipe.context && selectedRecipe.recipe.context.length > 0 && (

Context

@@ -926,30 +914,6 @@ Parameters you can use:
)} - {selectedRecipe.recipe.profile && ( -
-

Profile

-
- - {selectedRecipe.recipe.profile} - -
-
- )} - - {selectedRecipe.recipe.mcps && ( -
-

- Max Completion Tokens per Second -

-
- - {selectedRecipe.recipe.mcps} - -
-
- )} - {selectedRecipe.recipe.author && (

Author

diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index d74b630863b4..e929c01567fd 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -1,3 +1,4 @@ +import { listRecipes, RecipeManifest } from '../api'; import { Recipe } from './index'; import * as yaml from 'yaml'; @@ -162,60 +163,12 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise } } -/** - * List all saved recipes from the recipes directories. - * - * Uses the listFiles API to find available recipe files. - */ -export async function listSavedRecipes(includeArchived: boolean = false): Promise { +export async function listSavedRecipes( + includeArchived: boolean = false +): Promise { try { - // Check for global and local recipe directories - const globalDir = getStorageDirectory(true); - const localDir = getStorageDirectory(false); - - // Ensure directories exist - await window.electron.ensureDirectory(globalDir); - await window.electron.ensureDirectory(localDir); - - // Get list of recipe files with .yaml extension - const globalFiles = await window.electron.listFiles(globalDir, 'yaml'); - const localFiles = await window.electron.listFiles(localDir, 'yaml'); - - // Process global recipes in parallel - const globalRecipePromises = globalFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, true); - }); - - // Process local recipes in parallel - const localRecipePromises = localFiles.map(async (file) => { - const recipeName = file.replace(/\.yaml$/, ''); - return await loadRecipeFromFile(recipeName, false); - }); - - // Wait for all recipes to load in parallel - const [globalRecipes, localRecipes] = await Promise.all([ - Promise.all(globalRecipePromises), - Promise.all(localRecipePromises), - ]); - - // Filter out null results and apply archived filter - const recipes: SavedRecipe[] = []; - - for (const recipe of globalRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - for (const recipe of localRecipes) { - if (recipe && (includeArchived || !recipe.isArchived)) { - recipes.push(recipe); - } - } - - // Sort by last modified (newest first) - return recipes.sort((a, b) => b.lastModified.getTime() - a.lastModified.getTime()); + const recipes = await listRecipes({ query: { include_archived: includeArchived } }); + return recipes?.data?.recipe_manifests ?? []; } catch (error) { console.warn('Failed to list saved recipes:', error); return []; From f9510c43eb9ca91fdea39f50c589a70340c33c3d Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Fri, 29 Aug 2025 17:51:54 +1000 Subject: [PATCH 07/18] return recipe id --- Cargo.lock | 7 +++ crates/goose-server/src/openapi.rs | 1 + crates/goose-server/src/routes/recipe.rs | 29 ++++++++-- crates/goose-server/src/state.rs | 9 ++++ crates/goose/Cargo.toml | 1 + crates/goose/src/recipe/list_recipes.rs | 62 ++++++++++++++-------- crates/goose/src/recipe/mod.rs | 4 +- crates/goose/src/recipe/recipe_manifest.rs | 8 +-- ui/desktop/openapi.json | 21 ++++++-- ui/desktop/src/api/types.gen.ts | 7 ++- ui/desktop/src/components/RecipesView.tsx | 36 +++++++------ ui/desktop/src/recipe/recipeStorage.ts | 8 +-- 12 files changed, 139 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f77b5dbff72c..9b0f76f749a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2615,6 +2615,7 @@ dependencies = [ "webbrowser 0.8.15", "winapi", "wiremock", + "xxhash-rust", ] [[package]] @@ -7938,6 +7939,12 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 216f8209f900..a73165cded85 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -465,6 +465,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, super::routes::recipe::ListRecipeRequest, + super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, goose::recipe::Recipe, goose::recipe::Author, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 89b00fcca0db..007cbfe53a96 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use axum::extract::Query; @@ -77,9 +78,15 @@ pub struct ListRecipeRequest { include_archived: bool, } +#[derive(Debug, Serialize, ToSchema)] +pub struct RecipeManifestResponse { + manifest: RecipeManifest, + id: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct ListRecipeResponse { - recipe_manifests: Vec + recipe_manifest_responses: Vec, } #[utoipa::path( @@ -244,12 +251,28 @@ async fn list_recipes( Query(request): Query, ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; + let recipe_manifest_with_paths = + list_sorted_recipe_manifests(request.include_archived).unwrap(); + let mut recipe_file_hash_map = HashMap::new(); + let recipe_manifest_responses = recipe_manifest_with_paths + .iter() + .map(|recipe_manifest_with_path| { + let id = &recipe_manifest_with_path.id; + let file_path = recipe_manifest_with_path.file_path.clone(); + recipe_file_hash_map.insert(id.clone(), file_path); + RecipeManifestResponse { + manifest: recipe_manifest_with_path.manifest.clone(), + id: id.clone(), + } + }) + .collect::>(); + state.set_recipe_file_hash_map(recipe_file_hash_map).await; + Ok(Json(ListRecipeResponse { - recipe_manifests: list_sorted_recipe_manifests(request.include_archived).unwrap(), + recipe_manifest_responses, })) } - pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) diff --git a/crates/goose-server/src/state.rs b/crates/goose-server/src/state.rs index 720912b0c4f8..e72363f4bea0 100644 --- a/crates/goose-server/src/state.rs +++ b/crates/goose-server/src/state.rs @@ -1,5 +1,7 @@ use goose::agents::Agent; use goose::scheduler_trait::SchedulerTrait; +use std::collections::HashMap; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::Mutex; @@ -10,6 +12,7 @@ pub struct AppState { agent: Option, pub secret_key: String, pub scheduler: Arc>>>, + pub recipe_file_hash_map: Arc>>, } impl AppState { @@ -18,6 +21,7 @@ impl AppState { agent: Some(agent.clone()), secret_key, scheduler: Arc::new(Mutex::new(None)), + recipe_file_hash_map: Arc::new(Mutex::new(HashMap::new())), }) } @@ -39,4 +43,9 @@ impl AppState { .clone() .ok_or_else(|| anyhow::anyhow!("Scheduler not initialized")) } + + pub async fn set_recipe_file_hash_map(&self, hash_map: HashMap) { + let mut map = self.recipe_file_hash_map.lock().await; + *map = hash_map; + } } diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index b8653bec3b5a..dc856344df0b 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -99,6 +99,7 @@ unicode-normalization = "0.1" arrow = "52.2" oauth2 = "5.0.0" +xxhash-rust = { version = "0.8.12", features = ["xxh3"] } [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs index 932f6e912e1e..de5d91407617 100644 --- a/crates/goose/src/recipe/list_recipes.rs +++ b/crates/goose/src/recipe/list_recipes.rs @@ -1,5 +1,6 @@ use std::fs; use std::path::PathBuf; +use xxhash_rust::xxh3::xxh3_64; use anyhow::Result; use etcetera::{choose_app_strategy, AppStrategy}; @@ -7,42 +8,61 @@ use etcetera::{choose_app_strategy, AppStrategy}; use crate::config::APP_STRATEGY; use crate::recipe::recipe_manifest::RecipeManifest; -fn load_recipes_from_path(path: &PathBuf) -> Result> { - let mut recipe_manifests = Vec::new(); +pub struct RecipeManifestWithPath { + pub id: String, + pub manifest: RecipeManifest, + pub file_path: PathBuf, +} + +fn short_id_from_path(path: &str) -> String { + let hash = xxh3_64(path.as_bytes()); + format!("{:016x}", hash) +} + +fn load_recipes_from_path(path: &PathBuf) -> Result> { + let mut recipe_manifests_with_path = Vec::new(); if path.exists() { for entry in fs::read_dir(path)? { let path = entry?.path(); if path.extension() == Some("yaml".as_ref()) { + let absolute_path = path.canonicalize()?; if let Ok(recipe_manifest) = RecipeManifest::from_yaml_file(&path) { - recipe_manifests.push(recipe_manifest); + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(&absolute_path.to_string_lossy()), + manifest: recipe_manifest, + file_path: absolute_path, + }; + recipe_manifests_with_path.push(manifest_with_path); } } } } - Ok(recipe_manifests) + Ok(recipe_manifests_with_path) } -fn get_all_recipes_manifests() -> Result> { +fn get_all_recipes_manifests() -> Result> { let current_dir = std::env::current_dir()?; let local_recipe_path = current_dir.join(".goose/recipes"); - + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) - .map(|strategy| strategy.in_config_dir("recipes")) - .unwrap_or_else(|_| PathBuf::from("~/.config/goose/recipes")); - - let mut recipe_manifests = Vec::new(); - - recipe_manifests.extend(load_recipes_from_path(&local_recipe_path)?); - recipe_manifests.extend(load_recipes_from_path(&global_recipe_path)?); - - Ok(recipe_manifests) + .map(|strategy| strategy.in_config_dir("recipes")) + .unwrap_or_else(|_| PathBuf::from("~/.config/goose/recipes")); + + let mut recipe_manifests_with_path = Vec::new(); + + recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?); + recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?); + + Ok(recipe_manifests_with_path) } -pub fn list_sorted_recipe_manifests(include_archived: bool) -> Result> { - let mut recipe_manifests = get_all_recipes_manifests()?; +pub fn list_sorted_recipe_manifests(include_archived: bool) -> Result> { + let mut recipe_manifests_with_path = get_all_recipes_manifests()?; if !include_archived { - recipe_manifests.retain(|manifest| !manifest.is_archived); + recipe_manifests_with_path + .retain(|manifest_with_path| !manifest_with_path.manifest.is_archived); } - recipe_manifests.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); - Ok(recipe_manifests) -} \ No newline at end of file + recipe_manifests_with_path + .sort_by(|a, b| b.manifest.last_modified.cmp(&a.manifest.last_modified)); + Ok(recipe_manifests_with_path) +} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index 47f1ec84aad9..bfe287d59bef 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -11,10 +11,10 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub mod build_recipe; -pub mod read_recipe_file_content; -pub mod template_recipe; pub mod list_recipes; +pub mod read_recipe_file_content; pub mod recipe_manifest; +pub mod template_recipe; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs index c2beacb0ab98..731ce0bd18fe 100644 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -1,9 +1,9 @@ use anyhow::Result; use std::{fs, path::Path}; +use crate::recipe::Recipe; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use crate::recipe::Recipe; #[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct RecipeManifest { @@ -19,8 +19,10 @@ pub struct RecipeManifest { impl RecipeManifest { 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 manifest = serde_yaml::from_str::(&content).map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; + let content = fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("Failed to read file {}: {}", path.display(), e))?; + let manifest = serde_yaml::from_str::(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; Ok(manifest) } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index ad7360418bf4..53a2e8b6e40d 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2302,13 +2302,13 @@ "ListRecipeResponse": { "type": "object", "required": [ - "recipe_manifests" + "recipe_manifest_responses" ], "properties": { - "recipe_manifests": { + "recipe_manifest_responses": { "type": "array", "items": { - "$ref": "#/components/schemas/RecipeManifest" + "$ref": "#/components/schemas/RecipeManifestResponse" } } } @@ -2887,6 +2887,21 @@ } } }, + "RecipeManifestResponse": { + "type": "object", + "required": [ + "manifest", + "id" + ], + "properties": { + "id": { + "type": "string" + }, + "manifest": { + "$ref": "#/components/schemas/RecipeManifest" + } + } + }, "RecipeParameter": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 52a154339efd..5e14a7407929 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -329,7 +329,7 @@ export type ListRecipeRequest = { }; export type ListRecipeResponse = { - recipe_manifests: Array; + recipe_manifest_responses: Array; }; export type ListSchedulesResponse = { @@ -550,6 +550,11 @@ export type RecipeManifest = { recipe: Recipe; }; +export type RecipeManifestResponse = { + id: string; + manifest: RecipeManifest; +}; + export type RecipeParameter = { default?: string | null; description: string; diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index e4fd4bfc0929..7ef7080b257b 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -23,7 +23,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import { RecipeManifest } from '../api'; +import { RecipeManifest, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -31,7 +31,7 @@ interface RecipesViewProps { // @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { - const [savedRecipes, setSavedRecipes] = useState([]); + const [savedRecipes, setSavedRecipes] = useState([]); const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); @@ -99,8 +99,8 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { setShowSkeleton(true); setShowContent(false); setError(null); - const recipeManifests = await listSavedRecipes(); - setSavedRecipes(recipeManifests); + const recipeManifestResponses = await listSavedRecipes(); + setSavedRecipes(recipeManifestResponses); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load recipes'); console.error('Failed to load saved recipes:', err); @@ -373,24 +373,26 @@ Parameters you can use: }; // Render a recipe item - const RecipeItem = ({ recipeManifest }: { recipeManifest: RecipeManifest }) => ( + const RecipeItem = ({ + recipeManifestResponse: { manifest }, + }: { + recipeManifestResponse: RecipeManifestResponse; + }) => (
-

{recipeManifest.recipe.title}

- {recipeManifest.isGlobal ? ( +

{manifest.recipe.title}

+ {manifest.isGlobal ? ( ) : ( )}
-

- {recipeManifest.recipe.description} -

+

{manifest.recipe.description}

- {recipeManifest.lastModified} + {manifest.lastModified}
@@ -398,7 +400,7 @@ Parameters you can use:
diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 144b394964c5..48969d2090c5 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -178,6 +178,13 @@ export async function listSavedRecipes( } } +export function recipeLastModified(lastModified: string): string { + if (lastModified) { + return parseLastModified(lastModified).toLocaleDateString(); + } + return ''; +} + /** * Generate a suggested filename for a recipe based on its title. * From 030303dad34f474e7705c245d4bf58a388ee6ca1 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 01:43:36 +1000 Subject: [PATCH 11/18] removed archive --- crates/goose-server/src/openapi.rs | 2 - crates/goose-server/src/routes/recipe.rs | 31 ----------- crates/goose/src/recipe/recipe_manifest.rs | 61 ++++++++++++++++++++++ ui/desktop/src/components/RecipesView.tsx | 54 ++----------------- 4 files changed, 64 insertions(+), 84 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b769d0f6022d..336b4f2b1264 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -395,7 +395,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::decode_recipe, super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, - super::routes::recipe::archive_recipe, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -469,7 +468,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ListRecipeRequest, super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, - super::routes::recipe::ArchiveRecipeRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 2e43e635b535..d006b2eb45ca 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -84,11 +84,6 @@ pub struct RecipeManifestResponse { id: String, } -#[derive(Debug, Deserialize, ToSchema)] -pub struct ArchiveRecipeRequest { - id: String, -} - #[derive(Debug, Serialize, ToSchema)] pub struct ListRecipeResponse { recipe_manifest_responses: Vec, @@ -279,31 +274,6 @@ async fn list_recipes( })) } -#[utoipa::path( - post, - path = "/recipes/archive", - request_body = ArchiveRecipeRequest, - responses( - (status = 204, description = "Recipe archived successfully"), - (status = 401, description = "Unauthorized - Invalid or missing API key"), - (status = 500, description = "Internal server error") - ), - tag = "Recipe Management" -)] -async fn archive_recipe( - State(state): State>, - headers: HeaderMap, - Json(request): Json, -) -> StatusCode { - if verify_secret_key(&headers, &state).is_err() { - return StatusCode::UNAUTHORIZED; - } - let recipe_file_hash_map = state.recipe_file_hash_map.lock().await; - let file_path = recipe_file_hash_map.get(&request.id).unwrap(); - RecipeManifest::archive(file_path).unwrap(); - StatusCode::NO_CONTENT -} - pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -311,7 +281,6 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) - .route("/recipes/archive", post(archive_recipe)) .with_state(state) } diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs index acbe2c3a5f27..d664f8bef260 100644 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -42,4 +42,65 @@ impl RecipeManifest { manifest.save_to_yaml_file(file_path).unwrap(); Ok(()) } + +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn create_test_manifest() -> RecipeManifest { + RecipeManifest { + name: "test_recipe".to_string(), + recipe: Recipe::builder() + .title("Test Recipe") + .description("A test recipe") + .instructions("Test instructions") + .build() + .unwrap(), + is_global: false, + last_modified: "2025-01-01T00:00:00.000Z".to_string(), + is_archived: false, + } + } + + #[test] + fn test_save_and_load_yaml_file() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_manifest.yml"); + + let manifest = create_test_manifest(); + + manifest.clone().save_to_yaml_file(&file_path).unwrap(); + + let loaded_manifest = RecipeManifest::from_yaml_file(&file_path).unwrap(); + + assert_eq!(loaded_manifest.name, manifest.name); + assert_eq!(loaded_manifest.recipe.title, manifest.recipe.title); + assert_eq!(loaded_manifest.is_global, manifest.is_global); + assert_eq!(loaded_manifest.is_archived, manifest.is_archived); + } + + #[test] + fn test_archive() { + let temp_dir = tempdir().unwrap(); + let file_path = temp_dir.path().join("test_manifest.yml"); + + let manifest = create_test_manifest(); + manifest.save_to_yaml_file(&file_path).unwrap(); + + RecipeManifest::archive(&file_path).unwrap(); + + let archived_manifest = RecipeManifest::from_yaml_file(&file_path).unwrap(); + + assert!(archived_manifest.is_archived); + } + + #[test] + fn test_from_yaml_file_nonexistent() { + let result = RecipeManifest::from_yaml_file(&std::path::Path::new("nonexistent.yml")); + assert!(result.is_err()); + } } diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index bc3b08ce3344..fee6b51046f2 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -5,16 +5,7 @@ import { generateRecipeFilename, recipeLastModified, } from '../recipe/recipeStorage'; -import { - FileText, - Trash2, - Bot, - Calendar, - Globe, - Folder, - AlertCircle, - Download, -} from 'lucide-react'; +import { FileText, Bot, Calendar, Globe, Folder, AlertCircle, Download } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -23,7 +14,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import { archiveRecipe, RecipeManifest, RecipeManifestResponse } from '../api'; +import { RecipeManifest, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -135,34 +126,6 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleDeleteRecipe = async (recipeManifest: RecipeManifest, id: string) => { - // TODO: Use Electron's dialog API for confirmation - const result = await window.electron.showMessageBox({ - type: 'warning', - buttons: ['Cancel', 'Delete'], - defaultId: 0, - title: 'Delete Recipe', - message: `Are you sure you want to delete "${recipeManifest.name}"?`, - detail: 'Deleted recipes can be restored later.', - }); - - if (result.response !== 1) { - return; - } - - try { - await await archiveRecipe({ body: { id } }); - await loadSavedRecipes(); - toastSuccess({ - title: recipeManifest.name, - msg: 'Recipe archived successfully', - }); - } catch (err) { - console.error('Failed to archive recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to archive recipe'); - } - }; - const handlePreviewRecipe = async (recipeManifest: RecipeManifest) => { setSelectedRecipe(recipeManifest); setShowPreview(true); @@ -377,7 +340,7 @@ Parameters you can use: // Render a recipe item const RecipeItem = ({ - recipeManifestResponse: { manifest, id }, + recipeManifestResponse: { manifest }, }: { recipeManifestResponse: RecipeManifestResponse; }) => ( @@ -423,17 +386,6 @@ Parameters you can use: Preview -
From 73a3311542a9ac8b267cd76d3e2d1a333f6334d5 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 02:24:07 +1000 Subject: [PATCH 12/18] Revert "removed archive" This reverts commit 030303dad34f474e7705c245d4bf58a388ee6ca1. --- crates/goose-server/src/openapi.rs | 2 + crates/goose-server/src/routes/recipe.rs | 31 +++++++++++ crates/goose/src/recipe/recipe_manifest.rs | 61 ---------------------- ui/desktop/src/components/RecipesView.tsx | 54 +++++++++++++++++-- 4 files changed, 84 insertions(+), 64 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 336b4f2b1264..b769d0f6022d 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -395,6 +395,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::decode_recipe, super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, + super::routes::recipe::archive_recipe, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -468,6 +469,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ListRecipeRequest, super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, + super::routes::recipe::ArchiveRecipeRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index d006b2eb45ca..2e43e635b535 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -84,6 +84,11 @@ pub struct RecipeManifestResponse { id: String, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct ArchiveRecipeRequest { + id: String, +} + #[derive(Debug, Serialize, ToSchema)] pub struct ListRecipeResponse { recipe_manifest_responses: Vec, @@ -274,6 +279,31 @@ async fn list_recipes( })) } +#[utoipa::path( + post, + path = "/recipes/archive", + request_body = ArchiveRecipeRequest, + responses( + (status = 204, description = "Recipe archived successfully"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 500, description = "Internal server error") + ), + tag = "Recipe Management" +)] +async fn archive_recipe( + State(state): State>, + headers: HeaderMap, + Json(request): Json, +) -> StatusCode { + if verify_secret_key(&headers, &state).is_err() { + return StatusCode::UNAUTHORIZED; + } + let recipe_file_hash_map = state.recipe_file_hash_map.lock().await; + let file_path = recipe_file_hash_map.get(&request.id).unwrap(); + RecipeManifest::archive(file_path).unwrap(); + StatusCode::NO_CONTENT +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -281,6 +311,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) + .route("/recipes/archive", post(archive_recipe)) .with_state(state) } diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs index d664f8bef260..acbe2c3a5f27 100644 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -42,65 +42,4 @@ impl RecipeManifest { manifest.save_to_yaml_file(file_path).unwrap(); Ok(()) } - -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::tempdir; - - fn create_test_manifest() -> RecipeManifest { - RecipeManifest { - name: "test_recipe".to_string(), - recipe: Recipe::builder() - .title("Test Recipe") - .description("A test recipe") - .instructions("Test instructions") - .build() - .unwrap(), - is_global: false, - last_modified: "2025-01-01T00:00:00.000Z".to_string(), - is_archived: false, - } - } - - #[test] - fn test_save_and_load_yaml_file() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test_manifest.yml"); - - let manifest = create_test_manifest(); - - manifest.clone().save_to_yaml_file(&file_path).unwrap(); - - let loaded_manifest = RecipeManifest::from_yaml_file(&file_path).unwrap(); - - assert_eq!(loaded_manifest.name, manifest.name); - assert_eq!(loaded_manifest.recipe.title, manifest.recipe.title); - assert_eq!(loaded_manifest.is_global, manifest.is_global); - assert_eq!(loaded_manifest.is_archived, manifest.is_archived); - } - - #[test] - fn test_archive() { - let temp_dir = tempdir().unwrap(); - let file_path = temp_dir.path().join("test_manifest.yml"); - - let manifest = create_test_manifest(); - manifest.save_to_yaml_file(&file_path).unwrap(); - - RecipeManifest::archive(&file_path).unwrap(); - - let archived_manifest = RecipeManifest::from_yaml_file(&file_path).unwrap(); - - assert!(archived_manifest.is_archived); - } - - #[test] - fn test_from_yaml_file_nonexistent() { - let result = RecipeManifest::from_yaml_file(&std::path::Path::new("nonexistent.yml")); - assert!(result.is_err()); - } } diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index fee6b51046f2..bc3b08ce3344 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -5,7 +5,16 @@ import { generateRecipeFilename, recipeLastModified, } from '../recipe/recipeStorage'; -import { FileText, Bot, Calendar, Globe, Folder, AlertCircle, Download } from 'lucide-react'; +import { + FileText, + Trash2, + Bot, + Calendar, + Globe, + Folder, + AlertCircle, + Download, +} from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -14,7 +23,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import { RecipeManifest, RecipeManifestResponse } from '../api'; +import { archiveRecipe, RecipeManifest, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -126,6 +135,34 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; + const handleDeleteRecipe = async (recipeManifest: RecipeManifest, id: string) => { + // TODO: Use Electron's dialog API for confirmation + const result = await window.electron.showMessageBox({ + type: 'warning', + buttons: ['Cancel', 'Delete'], + defaultId: 0, + title: 'Delete Recipe', + message: `Are you sure you want to delete "${recipeManifest.name}"?`, + detail: 'Deleted recipes can be restored later.', + }); + + if (result.response !== 1) { + return; + } + + try { + await await archiveRecipe({ body: { id } }); + await loadSavedRecipes(); + toastSuccess({ + title: recipeManifest.name, + msg: 'Recipe archived successfully', + }); + } catch (err) { + console.error('Failed to archive recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to archive recipe'); + } + }; + const handlePreviewRecipe = async (recipeManifest: RecipeManifest) => { setSelectedRecipe(recipeManifest); setShowPreview(true); @@ -340,7 +377,7 @@ Parameters you can use: // Render a recipe item const RecipeItem = ({ - recipeManifestResponse: { manifest }, + recipeManifestResponse: { manifest, id }, }: { recipeManifestResponse: RecipeManifestResponse; }) => ( @@ -386,6 +423,17 @@ Parameters you can use: Preview + From 0c4fb89fa750ce6a38b76a1609a0565cfddbffb2 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 02:36:47 +1000 Subject: [PATCH 13/18] changed archive to delete --- crates/goose-server/src/openapi.rs | 4 +- crates/goose-server/src/routes/recipe.rs | 27 ++++--- crates/goose/src/recipe/recipe_manifest.rs | 9 --- ui/desktop/openapi.json | 83 +++++++++++----------- ui/desktop/src/api/sdk.gen.ts | 24 +++---- ui/desktop/src/api/types.gen.ts | 66 +++++++++-------- ui/desktop/src/components/RecipesView.tsx | 4 +- 7 files changed, 112 insertions(+), 105 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index b769d0f6022d..9bbd75319c25 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -395,7 +395,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::decode_recipe, super::routes::recipe::scan_recipe, super::routes::recipe::list_recipes, - super::routes::recipe::archive_recipe, + super::routes::recipe::delete_recipe, ), components(schemas( super::routes::config_management::UpsertConfigQuery, @@ -469,7 +469,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::ListRecipeRequest, super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, - super::routes::recipe::ArchiveRecipeRequest, + super::routes::recipe::DeleteRecipeRequest, goose::recipe::Recipe, goose::recipe::Author, goose::recipe::Settings, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 2e43e635b535..76c7d59d1561 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -1,4 +1,5 @@ use std::collections::HashMap; +use std::fs; use std::sync::Arc; use axum::extract::Query; @@ -85,7 +86,7 @@ pub struct RecipeManifestResponse { } #[derive(Debug, Deserialize, ToSchema)] -pub struct ArchiveRecipeRequest { +pub struct DeleteRecipeRequest { id: String, } @@ -281,26 +282,34 @@ async fn list_recipes( #[utoipa::path( post, - path = "/recipes/archive", - request_body = ArchiveRecipeRequest, + path = "/recipes/delete", + request_body = DeleteRecipeRequest, responses( - (status = 204, description = "Recipe archived successfully"), + (status = 204, description = "Recipe deleted successfully"), (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Recipe not found"), (status = 500, description = "Internal server error") ), tag = "Recipe Management" )] -async fn archive_recipe( +async fn delete_recipe( State(state): State>, headers: HeaderMap, - Json(request): Json, + Json(request): Json, ) -> StatusCode { if verify_secret_key(&headers, &state).is_err() { return StatusCode::UNAUTHORIZED; } let recipe_file_hash_map = state.recipe_file_hash_map.lock().await; - let file_path = recipe_file_hash_map.get(&request.id).unwrap(); - RecipeManifest::archive(file_path).unwrap(); + let file_path = match recipe_file_hash_map.get(&request.id) { + Some(path) => path, + None => return StatusCode::NOT_FOUND, + }; + + if let Err(_) = fs::remove_file(file_path) { + return StatusCode::INTERNAL_SERVER_ERROR; + } + StatusCode::NO_CONTENT } @@ -311,7 +320,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/decode", post(decode_recipe)) .route("/recipes/scan", post(scan_recipe)) .route("/recipes/list", get(list_recipes)) - .route("/recipes/archive", post(archive_recipe)) + .route("/recipes/delete", post(delete_recipe)) .with_state(state) } diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs index acbe2c3a5f27..bd9d1e4f3c8e 100644 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ b/crates/goose/src/recipe/recipe_manifest.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use chrono::Utc; use std::{fs, path::Path}; use crate::recipe::Recipe; @@ -34,12 +33,4 @@ impl RecipeManifest { .map_err(|e| anyhow::anyhow!("Failed to write file {}: {}", path.display(), e))?; Ok(()) } - - pub fn archive(file_path: &Path) -> Result<()> { - let mut manifest = Self::from_yaml_file(file_path)?; - manifest.is_archived = true; - manifest.last_modified = Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); - manifest.save_to_yaml_file(file_path).unwrap(); - Ok(()) - } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 9ae00efe911e..06e0fbbf5a92 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -789,35 +789,6 @@ ] } }, - "/recipes/archive": { - "post": { - "tags": [ - "Recipe Management" - ], - "operationId": "archive_recipe", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ArchiveRecipeRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Recipe archived successfully" - }, - "401": { - "description": "Unauthorized - Invalid or missing API key" - }, - "500": { - "description": "Internal server error" - } - } - } - }, "/recipes/create": { "post": { "tags": [ @@ -891,6 +862,38 @@ } } }, + "/recipes/delete": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "delete_recipe", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteRecipeRequest" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Recipe deleted successfully" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Recipe not found" + }, + "500": { + "description": "Internal server error" + } + } + } + }, "/recipes/encode": { "post": { "tags": [ @@ -1474,17 +1477,6 @@ } } }, - "ArchiveRecipeRequest": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string" - } - } - }, "Author": { "type": "object", "properties": { @@ -1793,6 +1785,17 @@ } } }, + "DeleteRecipeRequest": { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string" + } + } + }, "EmbeddedResource": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 32f509bbee04..4468b23fcf84 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -1,7 +1,7 @@ // This file is auto-generated by @hey-api/openapi-ts import type { Options as ClientOptions, TDataShape, Client } from './client'; -import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, ArchiveRecipeData, ArchiveRecipeResponses, ArchiveRecipeErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; +import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -234,17 +234,6 @@ export const manageContext = (options: Opt }); }; -export const archiveRecipe = (options: Options) => { - return (options.client ?? _heyApiClient).post({ - url: '/recipes/archive', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } - }); -}; - /** * Create a Recipe configuration from the current session */ @@ -270,6 +259,17 @@ export const decodeRecipe = (options: Opti }); }; +export const deleteRecipe = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/recipes/delete', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const encodeRecipe = (options: Options) => { return (options.client ?? _heyApiClient).post({ url: '/recipes/encode', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 048ebb0166c1..28019442daa3 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -16,10 +16,6 @@ export type Annotations = { timestamp?: string; }; -export type ArchiveRecipeRequest = { - id: string; -}; - export type Author = { contact?: string | null; metadata?: string | null; @@ -139,6 +135,10 @@ export type DecodeRecipeResponse = { recipe: Recipe; }; +export type DeleteRecipeRequest = { + id: string; +}; + export type EmbeddedResource = { annotations?: Annotations | { [key: string]: unknown; @@ -1500,33 +1500,6 @@ export type ManageContextResponses = { export type ManageContextResponse = ManageContextResponses[keyof ManageContextResponses]; -export type ArchiveRecipeData = { - body: ArchiveRecipeRequest; - path?: never; - query?: never; - url: '/recipes/archive'; -}; - -export type ArchiveRecipeErrors = { - /** - * Unauthorized - Invalid or missing API key - */ - 401: unknown; - /** - * Internal server error - */ - 500: unknown; -}; - -export type ArchiveRecipeResponses = { - /** - * Recipe archived successfully - */ - 204: void; -}; - -export type ArchiveRecipeResponse = ArchiveRecipeResponses[keyof ArchiveRecipeResponses]; - export type CreateRecipeData = { body: CreateRecipeRequest; path?: never; @@ -1581,6 +1554,37 @@ export type DecodeRecipeResponses = { export type DecodeRecipeResponse2 = DecodeRecipeResponses[keyof DecodeRecipeResponses]; +export type DeleteRecipeData = { + body: DeleteRecipeRequest; + path?: never; + query?: never; + url: '/recipes/delete'; +}; + +export type DeleteRecipeErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Recipe not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type DeleteRecipeResponses = { + /** + * Recipe deleted successfully + */ + 204: void; +}; + +export type DeleteRecipeResponse = DeleteRecipeResponses[keyof DeleteRecipeResponses]; + export type EncodeRecipeData = { body: EncodeRecipeRequest; path?: never; diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index bc3b08ce3344..e17661fc1b94 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -23,7 +23,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import { archiveRecipe, RecipeManifest, RecipeManifestResponse } from '../api'; +import { deleteRecipe, RecipeManifest, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -151,7 +151,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await await archiveRecipe({ body: { id } }); + await await deleteRecipe({ body: { id } }); await loadSavedRecipes(); toastSuccess({ title: recipeManifest.name, From 1753564bf33b598dde6a4eab5521d79c6ab79dab Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 02:43:04 +1000 Subject: [PATCH 14/18] ui change --- ui/desktop/src/components/RecipesView.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index e17661fc1b94..29672314dd5a 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -143,7 +143,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { defaultId: 0, title: 'Delete Recipe', message: `Are you sure you want to delete "${recipeManifest.name}"?`, - detail: 'Deleted recipes can be restored later.', + detail: 'Recipe file will be deleted.', }); if (result.response !== 1) { @@ -151,15 +151,15 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await await deleteRecipe({ body: { id } }); + await deleteRecipe({ body: { id } }); await loadSavedRecipes(); toastSuccess({ title: recipeManifest.name, - msg: 'Recipe archived successfully', + msg: 'Recipe deleted successfully', }); } catch (err) { - console.error('Failed to archive recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to archive recipe'); + console.error('Failed to delete recipe:', err); + setError(err instanceof Error ? err.message : 'Failed to delete recipe'); } }; From 987a587d74be05e7052fe4061df3648cec87bc34 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 02:48:46 +1000 Subject: [PATCH 15/18] list all recipes without include archived query --- crates/goose-server/src/openapi.rs | 1 - crates/goose-server/src/routes/recipe.rs | 17 +++-------------- crates/goose/src/recipe/list_recipes.rs | 6 +----- ui/desktop/openapi.json | 21 --------------------- ui/desktop/src/api/sdk.gen.ts | 4 ++-- ui/desktop/src/api/types.gen.ts | 8 +------- ui/desktop/src/recipe/recipeStorage.ts | 6 ++---- 7 files changed, 9 insertions(+), 54 deletions(-) diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 9bbd75319c25..cb1738dbb26b 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -466,7 +466,6 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { super::routes::recipe::DecodeRecipeResponse, super::routes::recipe::ScanRecipeRequest, super::routes::recipe::ScanRecipeResponse, - super::routes::recipe::ListRecipeRequest, super::routes::recipe::RecipeManifestResponse, super::routes::recipe::ListRecipeResponse, super::routes::recipe::DeleteRecipeRequest, diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 76c7d59d1561..02df6ac13d0f 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::fs; use std::sync::Arc; -use axum::extract::Query; use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; @@ -74,11 +73,6 @@ pub struct ScanRecipeResponse { has_security_warnings: bool, } -#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] -pub struct ListRecipeRequest { - include_archived: bool, -} - #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { manifest: RecipeManifest, @@ -241,9 +235,6 @@ async fn scan_recipe( #[utoipa::path( get, path = "/recipes/list", - params( - ListRecipeRequest - ), responses( (status = 200, description = "Get recipe list successfully", body = ListRecipeResponse), (status = 401, description = "Unauthorized - Invalid or missing API key"), @@ -254,12 +245,10 @@ async fn scan_recipe( async fn list_recipes( State(state): State>, headers: HeaderMap, - Query(request): Query, ) -> Result, StatusCode> { verify_secret_key(&headers, &state)?; - let recipe_manifest_with_paths = - list_sorted_recipe_manifests(request.include_archived).unwrap(); + let recipe_manifest_with_paths = list_sorted_recipe_manifests().unwrap(); let mut recipe_file_hash_map = HashMap::new(); let recipe_manifest_responses = recipe_manifest_with_paths .iter() @@ -305,11 +294,11 @@ async fn delete_recipe( Some(path) => path, None => return StatusCode::NOT_FOUND, }; - + if let Err(_) = fs::remove_file(file_path) { return StatusCode::INTERNAL_SERVER_ERROR; } - + StatusCode::NO_CONTENT } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs index de5d91407617..c9ad31cb8c0c 100644 --- a/crates/goose/src/recipe/list_recipes.rs +++ b/crates/goose/src/recipe/list_recipes.rs @@ -56,12 +56,8 @@ fn get_all_recipes_manifests() -> Result> { Ok(recipe_manifests_with_path) } -pub fn list_sorted_recipe_manifests(include_archived: bool) -> Result> { +pub fn list_sorted_recipe_manifests() -> Result> { let mut recipe_manifests_with_path = get_all_recipes_manifests()?; - if !include_archived { - recipe_manifests_with_path - .retain(|manifest_with_path| !manifest_with_path.manifest.is_archived); - } recipe_manifests_with_path .sort_by(|a, b| b.manifest.last_modified.cmp(&a.manifest.last_modified)); Ok(recipe_manifests_with_path) diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 06e0fbbf5a92..c215f199b665 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -933,16 +933,6 @@ "Recipe Management" ], "operationId": "list_recipes", - "parameters": [ - { - "name": "include_archived", - "in": "query", - "required": true, - "schema": { - "type": "boolean" - } - } - ], "responses": { "200": { "description": "Get recipe list successfully", @@ -2336,17 +2326,6 @@ } } }, - "ListRecipeRequest": { - "type": "object", - "required": [ - "include_archived" - ], - "properties": { - "include_archived": { - "type": "boolean" - } - } - }, "ListRecipeResponse": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 4468b23fcf84..208017617c7f 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -281,8 +281,8 @@ export const encodeRecipe = (options: Opti }); }; -export const listRecipes = (options: Options) => { - return (options.client ?? _heyApiClient).get({ +export const listRecipes = (options?: Options) => { + return (options?.client ?? _heyApiClient).get({ url: '/recipes/list', ...options }); diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 28019442daa3..166ec5ae7e23 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -336,10 +336,6 @@ export type KillJobResponse = { message: string; }; -export type ListRecipeRequest = { - include_archived: boolean; -}; - export type ListRecipeResponse = { recipe_manifest_responses: Array; }; @@ -1611,9 +1607,7 @@ export type EncodeRecipeResponse2 = EncodeRecipeResponses[keyof EncodeRecipeResp export type ListRecipesData = { body?: never; path?: never; - query: { - include_archived: boolean; - }; + query?: never; url: '/recipes/list'; }; diff --git a/ui/desktop/src/recipe/recipeStorage.ts b/ui/desktop/src/recipe/recipeStorage.ts index 48969d2090c5..2ad0872d53f3 100644 --- a/ui/desktop/src/recipe/recipeStorage.ts +++ b/ui/desktop/src/recipe/recipeStorage.ts @@ -166,11 +166,9 @@ export async function loadRecipe(recipeName: string, isGlobal: boolean): Promise } } -export async function listSavedRecipes( - includeArchived: boolean = false -): Promise { +export async function listSavedRecipes(): Promise { try { - const listRecipeResponse = await listRecipes({ query: { include_archived: includeArchived } }); + const listRecipeResponse = await listRecipes(); return listRecipeResponse?.data?.recipe_manifest_responses ?? []; } catch (error) { console.warn('Failed to list saved recipes:', error); From 85836c3b2b8893d6d96c458d32f78f8e98c18f1c Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 03:02:25 +1000 Subject: [PATCH 16/18] used built-in hash --- Cargo.lock | 7 ------- crates/goose/Cargo.toml | 1 - crates/goose/src/recipe/list_recipes.rs | 9 ++++++--- 3 files changed, 6 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b0f76f749a9..f77b5dbff72c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2615,7 +2615,6 @@ dependencies = [ "webbrowser 0.8.15", "winapi", "wiremock", - "xxhash-rust", ] [[package]] @@ -7939,12 +7938,6 @@ version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" -[[package]] -name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - [[package]] name = "yaml-rust" version = "0.4.5" diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index dc856344df0b..b8653bec3b5a 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -99,7 +99,6 @@ unicode-normalization = "0.1" arrow = "52.2" oauth2 = "5.0.0" -xxhash-rust = { version = "0.8.12", features = ["xxh3"] } [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs index c9ad31cb8c0c..8cbede9e920b 100644 --- a/crates/goose/src/recipe/list_recipes.rs +++ b/crates/goose/src/recipe/list_recipes.rs @@ -1,6 +1,7 @@ use std::fs; +use std::hash::DefaultHasher; use std::path::PathBuf; -use xxhash_rust::xxh3::xxh3_64; +use std::hash::{Hash, Hasher}; use anyhow::Result; use etcetera::{choose_app_strategy, AppStrategy}; @@ -15,8 +16,10 @@ pub struct RecipeManifestWithPath { } fn short_id_from_path(path: &str) -> String { - let hash = xxh3_64(path.as_bytes()); - format!("{:016x}", hash) + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let h = hasher.finish(); + format!("{:016x}", h) } fn load_recipes_from_path(path: &PathBuf) -> Result> { From 310e35f9d32a430ca4d176e87890a94996b6c8d5 Mon Sep 17 00:00:00 2001 From: Lifei Zhou Date: Sat, 30 Aug 2025 05:45:03 +1000 Subject: [PATCH 17/18] move recipe manifest to server as view object --- Cargo.lock | 1 + crates/goose-server/Cargo.toml | 1 + crates/goose-server/src/lib.rs | 1 + crates/goose-server/src/main.rs | 1 + crates/goose-server/src/openapi.rs | 2 +- .../goose-server/src/recipe/list_recipes.rs | 85 +++++++++++++++++++ crates/goose-server/src/recipe/mod.rs | 2 + .../src/recipe/recipe_manifest_metadata.rs | 48 +++++++++++ crates/goose-server/src/routes/recipe.rs | 17 ++-- crates/goose/src/recipe/list_recipes.rs | 67 --------------- crates/goose/src/recipe/mod.rs | 2 - crates/goose/src/recipe/recipe_manifest.rs | 36 -------- ui/desktop/openapi.json | 30 +++---- ui/desktop/src/api/types.gen.ts | 9 +- ui/desktop/src/components/RecipesView.tsx | 56 +++++------- ui/desktop/src/recipe/recipeStorage.ts | 2 +- 16 files changed, 190 insertions(+), 170 deletions(-) create mode 100644 crates/goose-server/src/recipe/list_recipes.rs create mode 100644 crates/goose-server/src/recipe/mod.rs create mode 100644 crates/goose-server/src/recipe/recipe_manifest_metadata.rs delete mode 100644 crates/goose/src/recipe/list_recipes.rs delete mode 100644 crates/goose/src/recipe/recipe_manifest.rs diff --git a/Cargo.lock b/Cargo.lock index f77b5dbff72c..56f80383051a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", diff --git a/crates/goose-server/Cargo.toml b/crates/goose-server/Cargo.toml index 7abfdd37f2e8..de4dbba76ccb 100644 --- a/crates/goose-server/Cargo.toml +++ b/crates/goose-server/Cargo.toml @@ -53,3 +53,4 @@ path = "src/bin/generate_schema.rs" [dev-dependencies] tower = "0.5" async-trait = "0.1" +tempfile = "3.15.0" diff --git a/crates/goose-server/src/lib.rs b/crates/goose-server/src/lib.rs index 36c83824c45b..6b6e655f2f18 100644 --- a/crates/goose-server/src/lib.rs +++ b/crates/goose-server/src/lib.rs @@ -1,4 +1,5 @@ pub mod openapi; +pub mod recipe; pub mod routes; pub mod state; diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index ccd285687d66..a1a865f39694 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -3,6 +3,7 @@ mod configuration; mod error; mod logging; mod openapi; +mod recipe; mod routes; mod state; diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index cb1738dbb26b..68c82970564f 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -477,7 +477,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema { goose::recipe::RecipeParameterRequirement, goose::recipe::Response, goose::recipe::SubRecipe, - goose::recipe::recipe_manifest::RecipeManifest, + super::recipe::recipe_manifest_metadata::RecipeManifestMetadata, goose::agents::types::RetryConfig, goose::agents::types::SuccessCheck, super::routes::agent::AddSubRecipesRequest, diff --git a/crates/goose-server/src/recipe/list_recipes.rs b/crates/goose-server/src/recipe/list_recipes.rs new file mode 100644 index 000000000000..03d4d393bb3c --- /dev/null +++ b/crates/goose-server/src/recipe/list_recipes.rs @@ -0,0 +1,85 @@ +use std::fs; +use std::hash::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::path::PathBuf; + +use anyhow::Result; +use etcetera::{choose_app_strategy, AppStrategy}; + +use crate::recipe::recipe_manifest_metadata::RecipeManifestMetadata; +use goose::config::APP_STRATEGY; +use goose::recipe::read_recipe_file_content::read_recipe_file; +use goose::recipe::Recipe; + +pub struct RecipeManifestWithPath { + pub id: String, + pub recipe_metadata: RecipeManifestMetadata, + pub recipe: Recipe, + pub file_path: PathBuf, + pub last_modified: String, +} + +fn short_id_from_path(path: &str) -> String { + let mut hasher = DefaultHasher::new(); + path.hash(&mut hasher); + let h = hasher.finish(); + format!("{:016x}", h) +} + +fn load_recipes_from_path(path: &PathBuf) -> Result> { + let mut recipe_manifests_with_path = Vec::new(); + if path.exists() { + for entry in fs::read_dir(path)? { + let path = entry?.path(); + if path.extension() == Some("yaml".as_ref()) { + let Ok(recipe_file) = read_recipe_file(path.clone()) else { + continue; + }; + let Ok(recipe) = Recipe::from_content(&recipe_file.content) else { + continue; + }; + let Ok(recipe_metadata) = RecipeManifestMetadata::from_yaml_file(&path) else { + continue; + }; + let Ok(last_modified) = fs::metadata(path.clone()).map(|m| { + chrono::DateTime::::from(m.modified().unwrap()).to_rfc3339() + }) else { + continue; + }; + + let manifest_with_path = RecipeManifestWithPath { + id: short_id_from_path(recipe_file.file_path.to_string_lossy().as_ref()), + recipe_metadata, + recipe, + file_path: recipe_file.file_path, + last_modified, + }; + recipe_manifests_with_path.push(manifest_with_path); + } + } + } + Ok(recipe_manifests_with_path) +} + +fn get_all_recipes_manifests() -> Result> { + let current_dir = std::env::current_dir()?; + let local_recipe_path = current_dir.join(".goose/recipes"); + + let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) + .expect("goose requires a home dir") + .config_dir() + .join("recipes"); + + let mut recipe_manifests_with_path = Vec::new(); + + recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?); + recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?); + + Ok(recipe_manifests_with_path) +} + +pub fn list_sorted_recipe_manifests() -> Result> { + let mut recipe_manifests_with_path = get_all_recipes_manifests()?; + recipe_manifests_with_path.sort_by(|a, b| b.last_modified.cmp(&a.last_modified)); + Ok(recipe_manifests_with_path) +} diff --git a/crates/goose-server/src/recipe/mod.rs b/crates/goose-server/src/recipe/mod.rs new file mode 100644 index 000000000000..b647b7353349 --- /dev/null +++ b/crates/goose-server/src/recipe/mod.rs @@ -0,0 +1,2 @@ +pub mod list_recipes; +pub mod recipe_manifest_metadata; diff --git a/crates/goose-server/src/recipe/recipe_manifest_metadata.rs b/crates/goose-server/src/recipe/recipe_manifest_metadata.rs new file mode 100644 index 000000000000..ca6cc3658b7b --- /dev/null +++ b/crates/goose-server/src/recipe/recipe_manifest_metadata.rs @@ -0,0 +1,48 @@ +use anyhow::Result; +use std::{fs, path::Path}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] +pub struct RecipeManifestMetadata { + pub name: String, + #[serde(rename = "isGlobal")] + pub is_global: bool, +} + +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"); + assert_eq!(result.is_global, true); + } +} diff --git a/crates/goose-server/src/routes/recipe.rs b/crates/goose-server/src/routes/recipe.rs index 02df6ac13d0f..93506bdba002 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -5,10 +5,11 @@ use std::sync::Arc; use axum::routing::get; use axum::{extract::State, http::StatusCode, routing::post, Json, Router}; use goose::conversation::{message::Message, Conversation}; -use goose::recipe::list_recipes::list_sorted_recipe_manifests; -use goose::recipe::recipe_manifest::RecipeManifest; use goose::recipe::Recipe; use goose::recipe_deeplink; + +use crate::recipe::list_recipes::list_sorted_recipe_manifests; +use crate::recipe::recipe_manifest_metadata::RecipeManifestMetadata; use http::HeaderMap; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -75,7 +76,11 @@ pub struct ScanRecipeResponse { #[derive(Debug, Serialize, ToSchema)] pub struct RecipeManifestResponse { - manifest: RecipeManifest, + #[serde(rename = "recipeManifestMetadata")] + recipe_manifest_metadata: RecipeManifestMetadata, + recipe: Recipe, + #[serde(rename = "lastModified")] + last_modified: String, id: String, } @@ -257,8 +262,10 @@ async fn list_recipes( let file_path = recipe_manifest_with_path.file_path.clone(); recipe_file_hash_map.insert(id.clone(), file_path); RecipeManifestResponse { - manifest: recipe_manifest_with_path.manifest.clone(), + recipe_manifest_metadata: recipe_manifest_with_path.recipe_metadata.clone(), + recipe: recipe_manifest_with_path.recipe.clone(), id: id.clone(), + last_modified: recipe_manifest_with_path.last_modified.clone(), } }) .collect::>(); @@ -295,7 +302,7 @@ async fn delete_recipe( None => return StatusCode::NOT_FOUND, }; - if let Err(_) = fs::remove_file(file_path) { + if fs::remove_file(file_path).is_err() { return StatusCode::INTERNAL_SERVER_ERROR; } diff --git a/crates/goose/src/recipe/list_recipes.rs b/crates/goose/src/recipe/list_recipes.rs deleted file mode 100644 index 8cbede9e920b..000000000000 --- a/crates/goose/src/recipe/list_recipes.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::fs; -use std::hash::DefaultHasher; -use std::path::PathBuf; -use std::hash::{Hash, Hasher}; - -use anyhow::Result; -use etcetera::{choose_app_strategy, AppStrategy}; - -use crate::config::APP_STRATEGY; -use crate::recipe::recipe_manifest::RecipeManifest; - -pub struct RecipeManifestWithPath { - pub id: String, - pub manifest: RecipeManifest, - pub file_path: PathBuf, -} - -fn short_id_from_path(path: &str) -> String { - let mut hasher = DefaultHasher::new(); - path.hash(&mut hasher); - let h = hasher.finish(); - format!("{:016x}", h) -} - -fn load_recipes_from_path(path: &PathBuf) -> Result> { - let mut recipe_manifests_with_path = Vec::new(); - if path.exists() { - for entry in fs::read_dir(path)? { - let path = entry?.path(); - if path.extension() == Some("yaml".as_ref()) { - let absolute_path = path.canonicalize()?; - if let Ok(recipe_manifest) = RecipeManifest::from_yaml_file(&path) { - let manifest_with_path = RecipeManifestWithPath { - id: short_id_from_path(&absolute_path.to_string_lossy()), - manifest: recipe_manifest, - file_path: absolute_path, - }; - recipe_manifests_with_path.push(manifest_with_path); - } - } - } - } - Ok(recipe_manifests_with_path) -} - -fn get_all_recipes_manifests() -> Result> { - let current_dir = std::env::current_dir()?; - let local_recipe_path = current_dir.join(".goose/recipes"); - - let global_recipe_path = choose_app_strategy(APP_STRATEGY.clone()) - .map(|strategy| strategy.in_config_dir("recipes")) - .unwrap_or_else(|_| PathBuf::from("~/.config/goose/recipes")); - - let mut recipe_manifests_with_path = Vec::new(); - - recipe_manifests_with_path.extend(load_recipes_from_path(&local_recipe_path)?); - recipe_manifests_with_path.extend(load_recipes_from_path(&global_recipe_path)?); - - Ok(recipe_manifests_with_path) -} - -pub fn list_sorted_recipe_manifests() -> Result> { - let mut recipe_manifests_with_path = get_all_recipes_manifests()?; - recipe_manifests_with_path - .sort_by(|a, b| b.manifest.last_modified.cmp(&a.manifest.last_modified)); - Ok(recipe_manifests_with_path) -} diff --git a/crates/goose/src/recipe/mod.rs b/crates/goose/src/recipe/mod.rs index bfe287d59bef..f3ad0c25566c 100644 --- a/crates/goose/src/recipe/mod.rs +++ b/crates/goose/src/recipe/mod.rs @@ -11,9 +11,7 @@ use serde::{Deserialize, Serialize}; use utoipa::ToSchema; pub mod build_recipe; -pub mod list_recipes; pub mod read_recipe_file_content; -pub mod recipe_manifest; pub mod template_recipe; pub const BUILT_IN_RECIPE_DIR_PARAM: &str = "recipe_dir"; diff --git a/crates/goose/src/recipe/recipe_manifest.rs b/crates/goose/src/recipe/recipe_manifest.rs deleted file mode 100644 index bd9d1e4f3c8e..000000000000 --- a/crates/goose/src/recipe/recipe_manifest.rs +++ /dev/null @@ -1,36 +0,0 @@ -use anyhow::Result; -use std::{fs, path::Path}; - -use crate::recipe::Recipe; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] -pub struct RecipeManifest { - pub name: String, - pub recipe: Recipe, - #[serde(rename = "isGlobal")] - pub is_global: bool, - #[serde(rename = "lastModified")] - pub last_modified: String, - #[serde(rename = "isArchived")] - pub is_archived: bool, -} - -impl RecipeManifest { - 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 manifest = serde_yaml::from_str::(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse YAML: {}", e))?; - Ok(manifest) - } - - pub fn save_to_yaml_file(self, path: &Path) -> Result<()> { - let content = serde_yaml::to_string(&self) - .map_err(|e| anyhow::anyhow!("Failed to serialize YAML: {}", e))?; - fs::write(path, content) - .map_err(|e| anyhow::anyhow!("Failed to write file {}: {}", path.display(), e))?; - Ok(()) - } -} diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index c215f199b665..c192a3be3212 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2887,45 +2887,41 @@ } } }, - "RecipeManifest": { + "RecipeManifestMetadata": { "type": "object", "required": [ "name", - "recipe", - "isGlobal", - "lastModified", - "isArchived" + "isGlobal" ], "properties": { - "isArchived": { - "type": "boolean" - }, "isGlobal": { "type": "boolean" }, - "lastModified": { - "type": "string" - }, "name": { "type": "string" - }, - "recipe": { - "$ref": "#/components/schemas/Recipe" } } }, "RecipeManifestResponse": { "type": "object", "required": [ - "manifest", + "recipeManifestMetadata", + "recipe", + "lastModified", "id" ], "properties": { "id": { "type": "string" }, - "manifest": { - "$ref": "#/components/schemas/RecipeManifest" + "lastModified": { + "type": "string" + }, + "recipe": { + "$ref": "#/components/schemas/Recipe" + }, + "recipeManifestMetadata": { + "$ref": "#/components/schemas/RecipeManifestMetadata" } } }, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 166ec5ae7e23..44698b70b34c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -550,17 +550,16 @@ export type Recipe = { version?: string; }; -export type RecipeManifest = { - isArchived: boolean; +export type RecipeManifestMetadata = { isGlobal: boolean; - lastModified: string; name: string; - recipe: Recipe; }; export type RecipeManifestResponse = { id: string; - manifest: RecipeManifest; + lastModified: string; + recipe: Recipe; + recipeManifestMetadata: RecipeManifestMetadata; }; export type RecipeParameter = { diff --git a/ui/desktop/src/components/RecipesView.tsx b/ui/desktop/src/components/RecipesView.tsx index 29672314dd5a..f405cd6faa13 100644 --- a/ui/desktop/src/components/RecipesView.tsx +++ b/ui/desktop/src/components/RecipesView.tsx @@ -3,18 +3,9 @@ import { listSavedRecipes, saveRecipe, generateRecipeFilename, - recipeLastModified, + convertToLocaleDateString, } from '../recipe/recipeStorage'; -import { - FileText, - Trash2, - Bot, - Calendar, - Globe, - Folder, - AlertCircle, - Download, -} from 'lucide-react'; +import { FileText, Trash2, Bot, Calendar, AlertCircle, Download } from 'lucide-react'; import { ScrollArea } from './ui/scroll-area'; import { Card } from './ui/card'; import { Button } from './ui/button'; @@ -23,7 +14,7 @@ import { MainPanelLayout } from './Layout/MainPanelLayout'; import { Recipe, decodeRecipe, generateDeepLink } from '../recipe'; import { toastSuccess, toastError } from '../toasts'; import { useEscapeKey } from '../hooks/useEscapeKey'; -import { deleteRecipe, RecipeManifest, RecipeManifestResponse } from '../api'; +import { deleteRecipe, RecipeManifestResponse } from '../api'; interface RecipesViewProps { onLoadRecipe?: (recipe: Recipe) => void; @@ -35,7 +26,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { const [loading, setLoading] = useState(true); const [showSkeleton, setShowSkeleton] = useState(true); const [error, setError] = useState(null); - const [selectedRecipe, setSelectedRecipe] = useState(null); + const [selectedRecipe, setSelectedRecipe] = useState(null); const [showPreview, setShowPreview] = useState(false); const [showContent, setShowContent] = useState(false); const [showImportDialog, setShowImportDialog] = useState(false); @@ -109,7 +100,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleLoadRecipe = async (recipeManifest: RecipeManifest) => { + const handleLoadRecipe = async (recipe: Recipe) => { try { // onLoadRecipe is not working for loading recipes. It looks correct // but the instructions are not flowing through to the server. @@ -125,7 +116,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { undefined, // dir undefined, // version undefined, // resumeSessionId - recipeManifest.recipe, // recipe config + recipe, // recipe config undefined // view type ); // } @@ -135,14 +126,14 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handleDeleteRecipe = async (recipeManifest: RecipeManifest, id: string) => { + const handleDeleteRecipe = async (recipeManifest: RecipeManifestResponse) => { // TODO: Use Electron's dialog API for confirmation const result = await window.electron.showMessageBox({ type: 'warning', 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.recipeManifestMetadata.name}"?`, detail: 'Recipe file will be deleted.', }); @@ -151,10 +142,10 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } try { - await deleteRecipe({ body: { id } }); + await deleteRecipe({ body: { id: recipeManifest.id } }); await loadSavedRecipes(); toastSuccess({ - title: recipeManifest.name, + title: recipeManifest.recipeManifestMetadata.name, msg: 'Recipe deleted successfully', }); } catch (err) { @@ -163,7 +154,7 @@ export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) { } }; - const handlePreviewRecipe = async (recipeManifest: RecipeManifest) => { + const handlePreviewRecipe = async (recipeManifest: RecipeManifestResponse) => { setSelectedRecipe(recipeManifest); setShowPreview(true); @@ -377,7 +368,8 @@ Parameters you can use: // Render a recipe item const RecipeItem = ({ - recipeManifestResponse: { manifest, id }, + recipeManifestResponse, + recipeManifestResponse: { recipe, lastModified }, }: { recipeManifestResponse: RecipeManifestResponse; }) => ( @@ -385,17 +377,12 @@ Parameters you can use:
-

{manifest.recipe.title}

- {manifest.isGlobal ? ( - - ) : ( - - )} +

{recipe.title}

-

{manifest.recipe.description}

+

{recipe.description}

- {recipeLastModified(manifest.lastModified)} + {convertToLocaleDateString(lastModified)}
@@ -403,7 +390,7 @@ Parameters you can use: