diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 22ee8492a171..04debe7dec2d 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -392,6 +392,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::recipe::set_recipe_slash_command, super::routes::recipe::save_recipe, super::routes::recipe::parse_recipe, + super::routes::recipe::recipe_to_yaml, super::routes::setup::start_openrouter_setup, super::routes::setup::start_tetrate_setup, super::routes::tunnel::start_tunnel, @@ -508,6 +509,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::errors::ErrorResponse, super::routes::recipe::ParseRecipeRequest, super::routes::recipe::ParseRecipeResponse, + super::routes::recipe::RecipeToYamlRequest, + super::routes::recipe::RecipeToYamlResponse, 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 f35d18d8d053..566b6e037190 100644 --- a/crates/goose-server/src/routes/recipe.rs +++ b/crates/goose-server/src/routes/recipe.rs @@ -137,6 +137,16 @@ pub struct SetSlashCommandRequest { slash_command: Option, } +#[derive(Debug, Deserialize, ToSchema)] +pub struct RecipeToYamlRequest { + recipe: Recipe, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct RecipeToYamlResponse { + yaml: String, +} + #[utoipa::path( post, path = "/recipes/create", @@ -520,6 +530,27 @@ async fn parse_recipe( Ok(Json(ParseRecipeResponse { recipe })) } +#[utoipa::path( + post, + path = "/recipes/to-yaml", + request_body = RecipeToYamlRequest, + responses( + (status = 200, description = "Recipe converted to YAML successfully", body = RecipeToYamlResponse), + (status = 400, description = "Bad request - Failed to convert recipe to YAML", body = ErrorResponse), + ), + tag = "Recipe Management" +)] +async fn recipe_to_yaml( + Json(request): Json, +) -> Result, ErrorResponse> { + let yaml = request.recipe.to_yaml().map_err(|e| ErrorResponse { + message: format!("Failed to convert recipe to YAML: {}", e), + status: StatusCode::BAD_REQUEST, + })?; + + Ok(Json(RecipeToYamlResponse { yaml })) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/recipes/create", post(create_recipe)) @@ -532,6 +563,7 @@ pub fn routes(state: Arc) -> Router { .route("/recipes/slash-command", post(set_recipe_slash_command)) .route("/recipes/save", post(save_recipe)) .route("/recipes/parse", post(parse_recipe)) + .route("/recipes/to-yaml", post(recipe_to_yaml)) .with_state(state) } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a5da5bf3d011..2b500fb25cc1 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1558,6 +1558,46 @@ } } }, + "/recipes/to-yaml": { + "post": { + "tags": [ + "Recipe Management" + ], + "operationId": "recipe_to_yaml", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipeToYamlRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Recipe converted to YAML successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RecipeToYamlResponse" + } + } + } + }, + "400": { + "description": "Bad request - Failed to convert recipe to YAML", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, "/reply": { "post": { "tags": [ @@ -4762,6 +4802,28 @@ "user_prompt" ] }, + "RecipeToYamlRequest": { + "type": "object", + "required": [ + "recipe" + ], + "properties": { + "recipe": { + "$ref": "#/components/schemas/Recipe" + } + } + }, + "RecipeToYamlResponse": { + "type": "object", + "required": [ + "yaml" + ], + "properties": { + "yaml": { + "type": "string" + } + } + }, "RedactedThinkingContent": { "type": "object", "required": [ diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 8ea9fa5c3d69..c0a2449959af 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; +import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecipeToYamlData, RecipeToYamlErrors, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen'; export type Options = Options2 & { /** @@ -315,6 +315,15 @@ export const setRecipeSlashCommand = (opti } }); +export const recipeToYaml = (options: Options) => (options.client ?? client).post({ + url: '/recipes/to-yaml', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } +}); + export const reply = (options: Options) => (options.client ?? client).sse.post({ url: '/reply', ...options, diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 7a63aa388b76..4c8ff01c4362 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -732,6 +732,14 @@ export type RecipeParameterInputType = 'string' | 'number' | 'boolean' | 'date' export type RecipeParameterRequirement = 'required' | 'optional' | 'user_prompt'; +export type RecipeToYamlRequest = { + recipe: Recipe; +}; + +export type RecipeToYamlResponse = { + yaml: string; +}; + export type RedactedThinkingContent = { data: string; }; @@ -2325,6 +2333,31 @@ export type SetRecipeSlashCommandResponses = { 200: unknown; }; +export type RecipeToYamlData = { + body: RecipeToYamlRequest; + path?: never; + query?: never; + url: '/recipes/to-yaml'; +}; + +export type RecipeToYamlErrors = { + /** + * Bad request - Failed to convert recipe to YAML + */ + 400: ErrorResponse; +}; + +export type RecipeToYamlError = RecipeToYamlErrors[keyof RecipeToYamlErrors]; + +export type RecipeToYamlResponses = { + /** + * Recipe converted to YAML successfully + */ + 200: RecipeToYamlResponse; +}; + +export type RecipeToYamlResponse2 = RecipeToYamlResponses[keyof RecipeToYamlResponses]; + export type ReplyData = { body: ChatRequest; path?: never; diff --git a/ui/desktop/src/components/McpApps/types.ts b/ui/desktop/src/components/McpApps/types.ts index 2d22ce51412f..43ce6ecade11 100644 --- a/ui/desktop/src/components/McpApps/types.ts +++ b/ui/desktop/src/components/McpApps/types.ts @@ -12,7 +12,11 @@ export type McpMethodParams = { export type McpMethodResponse = { 'ui/open-link': { status: string; message: string }; 'ui/message': { status: string; message: string }; - 'tools/call': { content: unknown[]; isError: boolean; structuredContent?: Record }; + 'tools/call': { + content: unknown[]; + isError: boolean; + structuredContent?: Record; + }; 'resources/read': { contents: unknown[] }; 'notifications/message': Record; ping: Record; diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 1a3bf015674f..77216979dd70 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -275,7 +275,7 @@ const getToolName = (toolCallName: string): string => { if (lastIndex === -1) return toolCallName; return toolCallName.substring(lastIndex + 2); -} +}; // Helper function to extract extension name for tooltip const getExtensionTooltip = (toolCallName: string): string | null => { diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index 544e5885a835..f6119780b8f4 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -11,13 +11,16 @@ import { Clock, Terminal, ExternalLink, + Share2, + Copy, + Download, } from 'lucide-react'; import { ScrollArea } from '../ui/scroll-area'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; import { Skeleton } from '../ui/skeleton'; import { MainPanelLayout } from '../Layout/MainPanelLayout'; -import { toastSuccess } from '../../toasts'; +import { toastSuccess, toastError } from '../../toasts'; import { useEscapeKey } from '../../hooks/useEscapeKey'; import { deleteRecipe, @@ -25,6 +28,7 @@ import { startAgent, scheduleRecipe, setRecipeSlashCommand, + recipeToYaml, } from '../../api'; import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm'; import CreateEditRecipeModal from './CreateEditRecipeModal'; @@ -38,10 +42,19 @@ import { trackRecipeDeleted, trackRecipeStarted, trackRecipeDeeplinkCopied, + trackRecipeYamlCopied, + trackRecipeExportedToFile, trackRecipeScheduled, trackRecipeSlashCommandSet, getErrorType, } from '../../utils/analytics'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuSeparator, +} from '../ui/dropdown-menu'; export default function RecipesView() { const setView = useNavigation(); @@ -217,13 +230,85 @@ export default function RecipesView() { } catch (error) { console.error('Failed to copy deeplink:', error); trackRecipeDeeplinkCopied(false, getErrorType(error)); - toastSuccess({ + toastError({ title: 'Copy failed', msg: 'Failed to copy deeplink to clipboard', }); } }; + const handleCopyYaml = async (recipeManifest: RecipeManifest) => { + try { + const response = await recipeToYaml({ + body: { recipe: recipeManifest.recipe }, + throwOnError: true, + }); + + if (!response.data?.yaml) { + throw new Error('No YAML data returned from API'); + } + + await navigator.clipboard.writeText(response.data.yaml); + trackRecipeYamlCopied(true); + toastSuccess({ + title: 'YAML copied', + msg: 'Recipe YAML has been copied to clipboard', + }); + } catch (error) { + console.error('Failed to copy YAML:', error); + trackRecipeYamlCopied(false, getErrorType(error)); + toastError({ + title: 'Copy failed', + msg: 'Failed to copy recipe YAML to clipboard', + }); + } + }; + + const handleExportFile = async (recipeManifest: RecipeManifest) => { + try { + const response = await recipeToYaml({ + body: { recipe: recipeManifest.recipe }, + throwOnError: true, + }); + + if (!response.data?.yaml) { + throw new Error('No YAML data returned from API'); + } + + const sanitizedTitle = (recipeManifest.recipe.title || 'recipe') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + + const filename = `${sanitizedTitle}.yaml`; + + const result = await window.electron.showSaveDialog({ + title: 'Export Recipe', + defaultPath: filename, + filters: [ + { name: 'YAML Files', extensions: ['yaml', 'yml'] }, + { name: 'All Files', extensions: ['*'] }, + ], + }); + + if (!result.canceled && result.filePath) { + await window.electron.writeFile(result.filePath, response.data.yaml); + trackRecipeExportedToFile(true); + toastSuccess({ + title: 'Recipe exported', + msg: `Recipe saved to ${result.filePath}`, + }); + } + } catch (error) { + console.error('Failed to export recipe:', error); + trackRecipeExportedToFile(false, getErrorType(error)); + toastError({ + title: 'Export failed', + msg: 'Failed to export recipe to file', + }); + } + }; + const handleOpenScheduleDialog = (recipeManifest: RecipeManifest) => { setScheduleRecipeManifest(recipeManifest); setScheduleCron(recipeManifest.schedule_cron || '0 0 14 * * *'); @@ -450,18 +535,34 @@ export default function RecipesView() { > - + + + + + e.stopPropagation()}> + handleCopyDeeplink(recipeManifestResponse)}> + + Copy Deeplink + + handleCopyYaml(recipeManifestResponse)}> + + Copy YAML + + + handleExportFile(recipeManifestResponse)}> + + Export to File + + +