diff --git a/ui/desktop/src/components/RecipeEditor.tsx b/ui/desktop/src/components/RecipeEditor.tsx
index a621d32c..7f10d326 100644
--- a/ui/desktop/src/components/RecipeEditor.tsx
+++ b/ui/desktop/src/components/RecipeEditor.tsx
@@ -10,6 +10,7 @@ import { FixedExtensionEntry } from './ConfigContext';
import RecipeActivityEditor from './RecipeActivityEditor';
import RecipeInfoModal from './RecipeInfoModal';
import RecipeExpandableInfo from './RecipeExpandableInfo';
+import { ScheduleFromRecipeModal } from './schedule/ScheduleFromRecipeModal';
// import ExtensionList from './settings_v2/extensions/subcomponents/ExtensionList';
interface RecipeEditorProps {
@@ -34,6 +35,7 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
const [extensionsLoaded, setExtensionsLoaded] = useState(false);
const [copied, setCopied] = useState(false);
const [isRecipeInfoModalOpen, setRecipeInfoModalOpen] = useState(false);
+ const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [recipeInfoModelProps, setRecipeInfoModelProps] = useState<{
label: string;
value: string;
@@ -331,6 +333,13 @@ export default function RecipeEditor({ config }: RecipeEditorProps) {
{/* Action Buttons */}
+
);
}
diff --git a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
index 1c79d4c5..c06d45db 100644
--- a/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
+++ b/ui/desktop/src/components/schedule/CreateScheduleModal.tsx
@@ -1,9 +1,12 @@
-import React, { useState, useEffect, FormEvent } from 'react';
+import React, { useState, useEffect, FormEvent, useCallback } from 'react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Select } from '../ui/Select';
import cronstrue from 'cronstrue';
+import * as yaml from 'yaml';
+import { Buffer } from 'buffer';
+import { Recipe } from '../../recipe';
type FrequencyValue = 'once' | 'hourly' | 'daily' | 'weekly' | 'monthly';
@@ -26,6 +29,39 @@ interface CreateScheduleModalProps {
apiErrorExternally: string | null;
}
+// Interface for clean extension in YAML
+interface CleanExtension {
+ name: string;
+ type: 'stdio' | 'sse' | 'builtin' | 'frontend';
+ cmd?: string;
+ args?: string[];
+ uri?: string;
+ display_name?: string;
+ tools?: unknown[];
+ instructions?: string;
+ env_keys?: string[];
+ timeout?: number;
+ description?: string;
+ bundled?: boolean;
+}
+
+// Interface for clean recipe in YAML
+interface CleanRecipe {
+ title: string;
+ description: string;
+ instructions: string;
+ prompt?: string;
+ activities?: string[];
+ extensions?: CleanExtension[];
+ goosehints?: string;
+ context?: string[];
+ profile?: string;
+ author?: {
+ contact?: string;
+ metadata?: string;
+ };
+}
+
const frequencies: FrequencyOption[] = [
{ value: 'once', label: 'Once' },
{ value: 'hourly', label: 'Hourly' },
@@ -51,6 +87,153 @@ const checkboxLabelClassName = 'flex items-center text-sm text-textStandard dark
const checkboxInputClassName =
'h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 mr-2';
+type SourceType = 'file' | 'deeplink';
+
+// Function to parse deep link and extract recipe config
+function parseDeepLink(deepLink: string): Recipe | null {
+ try {
+ const url = new URL(deepLink);
+ if (url.protocol !== 'goose:' || (url.hostname !== 'bot' && url.hostname !== 'recipe')) {
+ return null;
+ }
+
+ const configParam = url.searchParams.get('config');
+ if (!configParam) {
+ return null;
+ }
+
+ const configJson = Buffer.from(configParam, 'base64').toString('utf-8');
+ return JSON.parse(configJson) as Recipe;
+ } catch (error) {
+ console.error('Failed to parse deep link:', error);
+ return null;
+ }
+}
+
+// Function to convert recipe to YAML
+function recipeToYaml(recipe: Recipe): string {
+ // Create a clean recipe object for YAML conversion
+ const cleanRecipe: CleanRecipe = {
+ title: recipe.title,
+ description: recipe.description,
+ instructions: recipe.instructions,
+ };
+
+ if (recipe.prompt) {
+ cleanRecipe.prompt = recipe.prompt;
+ }
+
+ if (recipe.activities && recipe.activities.length > 0) {
+ cleanRecipe.activities = recipe.activities;
+ }
+
+ if (recipe.extensions && recipe.extensions.length > 0) {
+ cleanRecipe.extensions = recipe.extensions.map(ext => {
+ const cleanExt: CleanExtension = {
+ name: ext.name,
+ type: 'builtin', // Default type, will be overridden below
+ };
+
+ // Handle different extension types
+ if ('type' in ext && ext.type) {
+ cleanExt.type = ext.type as CleanExtension['type'];
+
+ // Add type-specific fields based on the ExtensionConfig union types
+ switch (ext.type) {
+ case 'sse':
+ if ('uri' in ext && ext.uri) {
+ cleanExt.uri = ext.uri as string;
+ }
+ break;
+ case 'stdio':
+ if ('cmd' in ext && ext.cmd) {
+ cleanExt.cmd = ext.cmd as string;
+ }
+ if ('args' in ext && ext.args) {
+ cleanExt.args = ext.args as string[];
+ }
+ break;
+ case 'builtin':
+ if ('display_name' in ext && ext.display_name) {
+ cleanExt.display_name = ext.display_name as string;
+ }
+ break;
+ case 'frontend':
+ if ('tools' in ext && ext.tools) {
+ cleanExt.tools = ext.tools as unknown[];
+ }
+ if ('instructions' in ext && ext.instructions) {
+ cleanExt.instructions = ext.instructions as string;
+ }
+ break;
+ }
+ } else {
+ // Fallback: try to infer type from available fields
+ if ('cmd' in ext && ext.cmd) {
+ cleanExt.type = 'stdio';
+ cleanExt.cmd = ext.cmd as string;
+ if ('args' in ext && ext.args) {
+ cleanExt.args = ext.args as string[];
+ }
+ } else if ('command' in ext && ext.command) {
+ // Handle legacy 'command' field by converting to 'cmd'
+ cleanExt.type = 'stdio';
+ cleanExt.cmd = ext.command as string;
+ } else if ('uri' in ext && ext.uri) {
+ cleanExt.type = 'sse';
+ cleanExt.uri = ext.uri as string;
+ } else if ('tools' in ext && ext.tools) {
+ cleanExt.type = 'frontend';
+ cleanExt.tools = ext.tools as unknown[];
+ if ('instructions' in ext && ext.instructions) {
+ cleanExt.instructions = ext.instructions as string;
+ }
+ } else {
+ // Default to builtin if we can't determine type
+ cleanExt.type = 'builtin';
+ }
+ }
+
+ // Add common optional fields
+ if (ext.env_keys && ext.env_keys.length > 0) {
+ cleanExt.env_keys = ext.env_keys;
+ }
+
+ if ('timeout' in ext && ext.timeout) {
+ cleanExt.timeout = ext.timeout as number;
+ }
+
+ if ('description' in ext && ext.description) {
+ cleanExt.description = ext.description as string;
+ }
+
+ if ('bundled' in ext && ext.bundled !== undefined) {
+ cleanExt.bundled = ext.bundled as boolean;
+ }
+
+ return cleanExt;
+ });
+ }
+
+ if (recipe.goosehints) {
+ cleanRecipe.goosehints = recipe.goosehints;
+ }
+
+ if (recipe.context && recipe.context.length > 0) {
+ cleanRecipe.context = recipe.context;
+ }
+
+ if (recipe.profile) {
+ cleanRecipe.profile = recipe.profile;
+ }
+
+ if (recipe.author) {
+ cleanRecipe.author = recipe.author;
+ }
+
+ return yaml.stringify(cleanRecipe);
+}
+
export const CreateScheduleModal: React.FC = ({
isOpen,
onClose,
@@ -59,7 +242,10 @@ export const CreateScheduleModal: React.FC = ({
apiErrorExternally,
}) => {
const [scheduleId, setScheduleId] = useState('');
+ const [sourceType, setSourceType] = useState('file');
const [recipeSourcePath, setRecipeSourcePath] = useState('');
+ const [deepLinkInput, setDeepLinkInput] = useState('');
+ const [parsedRecipe, setParsedRecipe] = useState(null);
const [frequency, setFrequency] = useState('daily');
const [selectedDate, setSelectedDate] = useState(
() => new Date().toISOString().split('T')[0]
@@ -72,9 +258,46 @@ export const CreateScheduleModal: React.FC = ({
const [readableCronExpression, setReadableCronExpression] = useState('');
const [internalValidationError, setInternalValidationError] = useState(null);
+ const handleDeepLinkChange = useCallback((value: string) => {
+ setDeepLinkInput(value);
+ setInternalValidationError(null);
+
+ if (value.trim()) {
+ const recipe = parseDeepLink(value.trim());
+ if (recipe) {
+ setParsedRecipe(recipe);
+ // Auto-populate schedule ID from recipe title if available
+ if (recipe.title && !scheduleId) {
+ const cleanId = recipe.title.toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-');
+ setScheduleId(cleanId);
+ }
+ } else {
+ setParsedRecipe(null);
+ setInternalValidationError('Invalid deep link format. Please use a goose://bot or goose://recipe link.');
+ }
+ } else {
+ setParsedRecipe(null);
+ }
+ }, [scheduleId]);
+
+ useEffect(() => {
+ // Check for pending deep link when modal opens
+ if (isOpen) {
+ const pendingDeepLink = localStorage.getItem('pendingScheduleDeepLink');
+ if (pendingDeepLink) {
+ localStorage.removeItem('pendingScheduleDeepLink');
+ setSourceType('deeplink');
+ handleDeepLinkChange(pendingDeepLink);
+ }
+ }
+ }, [isOpen, handleDeepLinkChange]);
+
const resetForm = () => {
setScheduleId('');
+ setSourceType('file');
setRecipeSourcePath('');
+ setDeepLinkInput('');
+ setParsedRecipe(null);
setFrequency('daily');
setSelectedDate(new Date().toISOString().split('T')[0]);
setSelectedTime('09:00');
@@ -195,10 +418,48 @@ export const CreateScheduleModal: React.FC = ({
setInternalValidationError('Schedule ID is required.');
return;
}
- if (!recipeSourcePath) {
- setInternalValidationError('Recipe source file is required.');
- return;
+
+ let finalRecipeSource = '';
+
+ if (sourceType === 'file') {
+ if (!recipeSourcePath) {
+ setInternalValidationError('Recipe source file is required.');
+ return;
+ }
+ finalRecipeSource = recipeSourcePath;
+ } else if (sourceType === 'deeplink') {
+ if (!deepLinkInput.trim()) {
+ setInternalValidationError('Deep link is required.');
+ return;
+ }
+ if (!parsedRecipe) {
+ setInternalValidationError('Invalid deep link. Please check the format.');
+ return;
+ }
+
+ try {
+ // Convert recipe to YAML and save to a temporary file
+ const yamlContent = recipeToYaml(parsedRecipe);
+ console.log('Generated YAML content:', yamlContent); // Debug log
+ const tempFileName = `schedule-${scheduleId}-${Date.now()}.yaml`;
+ const tempDir = window.electron.getConfig().GOOSE_WORKING_DIR || '.';
+ const tempFilePath = `${tempDir}/${tempFileName}`;
+
+ // Write the YAML file
+ const writeSuccess = await window.electron.writeFile(tempFilePath, yamlContent);
+ if (!writeSuccess) {
+ setInternalValidationError('Failed to create temporary recipe file.');
+ return;
+ }
+
+ finalRecipeSource = tempFilePath;
+ } catch (error) {
+ console.error('Failed to convert recipe to YAML:', error);
+ setInternalValidationError('Failed to process the recipe from deep link.');
+ return;
+ }
}
+
if (
!derivedCronExpression ||
derivedCronExpression.includes('Invalid') ||
@@ -216,7 +477,7 @@ export const CreateScheduleModal: React.FC = ({
const newSchedulePayload: NewSchedulePayload = {
id: scheduleId.trim(),
- recipe_source: recipeSourcePath,
+ recipe_source: finalRecipeSource,
cron: derivedCronExpression,
};
@@ -232,7 +493,7 @@ export const CreateScheduleModal: React.FC = ({
return (
-
+
Create New Schedule
@@ -268,22 +529,79 @@ export const CreateScheduleModal: React.FC = ({
required
/>
+
-
-
- {recipeSourcePath && (
-
- Selected: {recipeSourcePath}
-
- )}
+
+
+
+
+
+
+
+ {sourceType === 'file' && (
+
+
+ {recipeSourcePath && (
+
+ Selected: {recipeSourcePath}
+
+ )}
+
+ )}
+
+ {sourceType === 'deeplink' && (
+
+
handleDeepLinkChange(e.target.value)}
+ placeholder="Paste goose://bot or goose://recipe link here..."
+ />
+ {parsedRecipe && (
+
+
+ ✓ Recipe parsed successfully
+
+
+ Title: {parsedRecipe.title}
+
+
+ Description: {parsedRecipe.description}
+
+
+ )}
+
+ )}
+
+