From 5d53643b61e5446fd0a8d8eda7ee05fac6e80346 Mon Sep 17 00:00:00 2001 From: Kai Lu <72936797+kailu-square@users.noreply.github.com> Date: Wed, 3 Dec 2025 07:45:25 -0800 Subject: [PATCH] Enable recipe deeplink parameters for pre-population (#5757) Co-authored-by: Claude --- Cargo.lock | 1 + crates/goose-cli/Cargo.toml | 1 + crates/goose-cli/src/cli.rs | 30 ++- crates/goose-cli/src/commands/recipe.rs | 181 ++++++++++++++++-- ui/desktop/src/components/BaseChat.tsx | 4 + .../src/components/ParameterInputModal.tsx | 14 +- ui/desktop/src/hooks/useRecipeManager.ts | 32 +++- ui/desktop/src/main.ts | 72 +++++-- 8 files changed, 295 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1358745c..0ae2dd1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2789,6 +2789,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "url", + "urlencoding", "uuid", "webbrowser 1.0.4", "winapi", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index 7404a2f8..6f573d97 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -59,6 +59,7 @@ is-terminal = "0.4.16" anstream = "0.6.18" url = "2.5.7" open = "5.3.2" +urlencoding = "2.1" [target.'cfg(target_os = "windows")'.dependencies] winapi = { version = "0.3", features = ["wincred"] } diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 29705631..3d9af20a 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -379,6 +379,14 @@ enum RecipeCommand { help = "recipe name to get recipe file or full path to the recipe file to generate deeplink" )] recipe_name: String, + /// Recipe parameters in key=value format (can be specified multiple times) + #[arg( + short = 'p', + long = "param", + value_name = "KEY=VALUE", + help = "Recipe parameter in key=value format (can be specified multiple times)" + )] + params: Vec, }, /// Open a recipe in Goose Desktop @@ -387,6 +395,14 @@ enum RecipeCommand { /// Recipe name to get recipe file to open #[arg(help = "recipe name or full path to the recipe file")] recipe_name: String, + /// Recipe parameters in key=value format (can be specified multiple times) + #[arg( + short = 'p', + long = "param", + value_name = "KEY=VALUE", + help = "Recipe parameter in key=value format (can be specified multiple times)" + )] + params: Vec, }, /// List available recipes @@ -1448,11 +1464,17 @@ pub async fn cli() -> anyhow::Result<()> { RecipeCommand::Validate { recipe_name } => { handle_validate(&recipe_name)?; } - RecipeCommand::Deeplink { recipe_name } => { - handle_deeplink(&recipe_name)?; + RecipeCommand::Deeplink { + recipe_name, + params, + } => { + handle_deeplink(&recipe_name, ¶ms)?; } - RecipeCommand::Open { recipe_name } => { - handle_open(&recipe_name)?; + RecipeCommand::Open { + recipe_name, + params, + } => { + handle_open(&recipe_name, ¶ms)?; } RecipeCommand::List { format, verbose } => { handle_list(&format, verbose)?; diff --git a/crates/goose-cli/src/commands/recipe.rs b/crates/goose-cli/src/commands/recipe.rs index caeeddea..234da56d 100644 --- a/crates/goose-cli/src/commands/recipe.rs +++ b/crates/goose-cli/src/commands/recipe.rs @@ -1,6 +1,7 @@ use anyhow::Result; use console::style; use goose::recipe::validate_recipe::validate_recipe_template_from_file; +use std::collections::HashMap; use crate::recipes::github_recipe::RecipeSource; use crate::recipes::search_recipe::{list_available_recipes, load_recipe_file}; @@ -20,8 +21,9 @@ pub fn handle_validate(recipe_name: &str) -> Result<()> { Ok(()) } -pub fn handle_deeplink(recipe_name: &str) -> Result { - match generate_deeplink(recipe_name) { +pub fn handle_deeplink(recipe_name: &str, params: &[String]) -> Result { + let params_map = parse_params(params)?; + match generate_deeplink(recipe_name, params_map) { Ok((deeplink_url, recipe)) => { println!( "{} Generated deeplink for: {}", @@ -42,10 +44,11 @@ pub fn handle_deeplink(recipe_name: &str) -> Result { } } -pub fn handle_open(recipe_name: &str) -> Result<()> { +pub fn handle_open(recipe_name: &str, params: &[String]) -> Result<()> { // Generate the deeplink using the helper function (no printing) // This reuses all the validation and encoding logic - match generate_deeplink(recipe_name) { + let params_map = parse_params(params)?; + match generate_deeplink(recipe_name, params_map) { Ok((deeplink_url, recipe)) => { // Attempt to open the deeplink match open::that(&deeplink_url) { @@ -131,13 +134,40 @@ pub fn handle_list(format: &str, verbose: bool) -> Result<()> { Ok(()) } -fn generate_deeplink(recipe_name: &str) -> Result<(String, goose::recipe::Recipe)> { +fn parse_params(params: &[String]) -> Result> { + let mut params_map = HashMap::new(); + for param in params { + let parts: Vec<&str> = param.splitn(2, '=').collect(); + if parts.len() != 2 { + return Err(anyhow::anyhow!( + "Invalid parameter format: '{}'. Expected format: key=value", + param + )); + } + params_map.insert(parts[0].to_string(), parts[1].to_string()); + } + Ok(params_map) +} + +fn generate_deeplink( + recipe_name: &str, + params: HashMap, +) -> Result<(String, goose::recipe::Recipe)> { let recipe_file = load_recipe_file(recipe_name)?; // Load the recipe file first to validate it let recipe = validate_recipe_template_from_file(&recipe_file)?; match recipe_deeplink::encode(&recipe) { Ok(encoded) => { - let full_url = format!("goose://recipe?config={}", encoded); + let mut full_url = format!("goose://recipe?config={}", encoded); + + // Append parameters as additional query parameters + for (key, value) in params { + // URL-encode the parameter keys and values + let encoded_key = urlencoding::encode(&key); + let encoded_value = urlencoding::encode(&value); + full_url.push_str(&format!("&{}={}", encoded_key, encoded_value)); + } + Ok((full_url, recipe)) } Err(err) => Err(anyhow::anyhow!("Failed to encode recipe: {}", err)), @@ -188,7 +218,7 @@ instructions: "Test instructions" let recipe_path = create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); - let result = handle_deeplink(&recipe_path); + let result = handle_deeplink(&recipe_path, &[]); assert!(result.is_ok()); let url = result.unwrap(); assert!(url.starts_with("goose://recipe?config=")); @@ -196,12 +226,27 @@ instructions: "Test instructions" assert!(!encoded_part.is_empty()); } + #[test] + fn test_handle_deeplink_with_parameters() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let params = vec!["name=John".to_string(), "age=30".to_string()]; + let result = handle_deeplink(&recipe_path, ¶ms); + assert!(result.is_ok()); + let url = result.unwrap(); + assert!(url.starts_with("goose://recipe?config=")); + assert!(url.contains("&name=John")); + assert!(url.contains("&age=30")); + } + #[test] fn test_handle_deeplink_invalid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let recipe_path = create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); - let result = handle_deeplink(&recipe_path); + let result = handle_deeplink(&recipe_path, &[]); assert!(result.is_err()); } @@ -213,7 +258,7 @@ instructions: "Test instructions" // Test handle_open - should attempt to open but may fail (that's expected in test environment) // We just want to ensure it doesn't panic and handles the error gracefully - let result = handle_open(&recipe_path); + let result = handle_open(&recipe_path, &[]); // The result may be Ok or Err depending on whether the system can open the URL // In a test environment, it will likely fail to open, but that's fine // We're mainly testing that the function doesn't panic and processes the recipe correctly @@ -227,6 +272,27 @@ instructions: "Test instructions" } } + #[test] + fn test_handle_open_with_parameters() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let params = vec!["name=Alice".to_string(), "role=developer".to_string()]; + let result = handle_open(&recipe_path, ¶ms); + // The result may be Ok or Err depending on whether the system can open the URL + // In a test environment, it will likely fail to open, but that's fine + // We're mainly testing that the function processes parameters correctly and doesn't panic + match result { + Ok(_) => { + // Successfully opened (unlikely in test environment) + } + Err(_) => { + // Failed to open (expected in test environment) - this is fine + } + } + } + #[test] fn test_handle_validation_valid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); @@ -252,7 +318,7 @@ instructions: "Test instructions" let recipe_path = create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); - let result = generate_deeplink(&recipe_path); + let result = generate_deeplink(&recipe_path, HashMap::new()); assert!(result.is_ok()); let (url, recipe) = result.unwrap(); assert!(url.starts_with("goose://recipe?config=")); @@ -262,13 +328,106 @@ instructions: "Test instructions" assert!(!encoded_part.is_empty()); } + #[test] + fn test_generate_deeplink_with_parameters() { + let temp_dir = TempDir::new().expect("Failed to create temp directory"); + let recipe_path = + create_test_recipe_file(&temp_dir, "test_recipe.yaml", VALID_RECIPE_CONTENT); + + let mut params = HashMap::new(); + params.insert("name".to_string(), "Alice".to_string()); + params.insert("role".to_string(), "developer".to_string()); + + let result = generate_deeplink(&recipe_path, params); + assert!(result.is_ok()); + let (url, recipe) = result.unwrap(); + assert!(url.starts_with("goose://recipe?config=")); + assert!(url.contains("&name=Alice")); + assert!(url.contains("&role=developer")); + assert_eq!(recipe.title, "Test Recipe with Valid JSON Schema"); + } + #[test] fn test_generate_deeplink_invalid_recipe() { let temp_dir = TempDir::new().expect("Failed to create temp directory"); let recipe_path = create_test_recipe_file(&temp_dir, "test_recipe.yaml", INVALID_RECIPE_CONTENT); - let result = generate_deeplink(&recipe_path); + let result = generate_deeplink(&recipe_path, HashMap::new()); assert!(result.is_err()); } + + #[test] + fn test_parse_params_basic() { + let params = vec!["name=John".to_string(), "age=30".to_string()]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + assert_eq!(map.get("name"), Some(&"John".to_string())); + assert_eq!(map.get("age"), Some(&"30".to_string())); + } + + #[test] + fn test_parse_params_with_equals_in_value() { + let params = vec!["key=value=with=equals".to_string()]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + assert_eq!(map.get("key"), Some(&"value=with=equals".to_string())); + } + + #[test] + fn test_parse_params_empty_value() { + let params = vec!["key=".to_string()]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + assert_eq!(map.get("key"), Some(&"".to_string())); + } + + #[test] + fn test_parse_params_no_equals() { + let params = vec!["invalid".to_string()]; + let result = parse_params(¶ms); + assert!(result.is_err()); + let error_msg = result.unwrap_err().to_string(); + assert!(error_msg.contains("Invalid parameter format")); + } + + #[test] + fn test_parse_params_empty_key() { + let params = vec!["=value".to_string()]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + // Empty key is technically valid according to current implementation + assert_eq!(map.get(""), Some(&"value".to_string())); + } + + #[test] + fn test_parse_params_special_characters() { + let params = vec![ + "url=https://example.com/path?query=test".to_string(), + "message=Hello World!".to_string(), + "email=user@example.com".to_string(), + ]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + assert_eq!( + map.get("url"), + Some(&"https://example.com/path?query=test".to_string()) + ); + assert_eq!(map.get("message"), Some(&"Hello World!".to_string())); + assert_eq!(map.get("email"), Some(&"user@example.com".to_string())); + } + + #[test] + fn test_parse_params_empty_list() { + let params: Vec = vec![]; + let result = parse_params(¶ms); + assert!(result.is_ok()); + let map = result.unwrap(); + assert!(map.is_empty()); + } } diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index 9adee717..f0165fb0 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -449,6 +449,10 @@ function BaseChatContent({ parameters={recipe.parameters} onSubmit={setRecipeUserParams} onClose={() => setView('chat')} + initialValues={ + (window.appConfig?.get('recipeParameters') as Record | undefined) || + undefined + } /> )} diff --git a/ui/desktop/src/components/ParameterInputModal.tsx b/ui/desktop/src/components/ParameterInputModal.tsx index 023da48b..fec16b6d 100644 --- a/ui/desktop/src/components/ParameterInputModal.tsx +++ b/ui/desktop/src/components/ParameterInputModal.tsx @@ -6,29 +6,31 @@ interface ParameterInputModalProps { parameters: Parameter[]; onSubmit: (values: Record) => void; onClose: () => void; + initialValues?: Record; } const ParameterInputModal: React.FC = ({ parameters, onSubmit, onClose, + initialValues, }) => { const [inputValues, setInputValues] = useState>({}); const [validationErrors, setValidationErrors] = useState>({}); const [showCancelOptions, setShowCancelOptions] = useState(false); - // Pre-fill the form with default values from the recipe + // Pre-fill the form with default values from the recipe and initialValues from deeplink useEffect(() => { - const initialValues: Record = {}; + const defaultValues: Record = {}; parameters.forEach((param) => { if (param.requirement === 'optional' && param.default) { - const defaultValue = + defaultValues[param.key] = param.input_type === 'boolean' ? param.default.toLowerCase() : param.default; - initialValues[param.key] = defaultValue; } }); - setInputValues(initialValues); - }, [parameters]); + + setInputValues({ ...defaultValues, ...initialValues }); + }, [parameters, initialValues]); const handleChange = (name: string, value: string): void => { setInputValues((prevValues: Record) => ({ ...prevValues, [name]: value })); diff --git a/ui/desktop/src/hooks/useRecipeManager.ts b/ui/desktop/src/hooks/useRecipeManager.ts index f8327463..e5240b83 100644 --- a/ui/desktop/src/hooks/useRecipeManager.ts +++ b/ui/desktop/src/hooks/useRecipeManager.ts @@ -22,6 +22,12 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { const chatContext = useChatContext(); const messages = chat.messages; + // Get recipe parameters from deeplink if available + const paramsFromConfig = + (window.appConfig?.get('recipeParameters') as Record | null | undefined) ?? + null; + const recipeParametersFromConfig = useRef | null>(paramsFromConfig); + const messagesRef = useRef(messages); const isCreatingRecipeRef = useRef(false); const hasCheckedRecipeRef = useRef(false); @@ -32,6 +38,27 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { const finalRecipe = chat.recipe; const resolvedRecipe = chat.resolvedRecipe; + + // Initialize parameters from deeplink when recipe is loaded (from backend/deeplink) + useEffect(() => { + if (!chatContext || !finalRecipe) { + return; + } + + // Only initialize if we have params from config and haven't set them yet + const hasNoParameters = + !chat.recipeParameterValues || + (typeof chat.recipeParameterValues === 'object' && + Object.keys(chat.recipeParameterValues).length === 0); + + if (recipeParametersFromConfig.current && hasNoParameters) { + chatContext.setChat({ + ...chatContext.chat, + recipeParameterValues: recipeParametersFromConfig.current, + }); + } + }, [chatContext, finalRecipe, chat]); + useEffect(() => { if (!chatContext) return; @@ -55,10 +82,13 @@ export const useRecipeManager = (chat: ChatType, recipe?: Recipe | null) => { setIsRecipeWarningModalOpen(false); hasCheckedRecipeRef.current = false; // Reset check flag for new recipe + // Initialize with parameters from deeplink if available + const initialParameterValues = recipeParametersFromConfig.current || null; + chatContext.setChat({ ...chatContext.chat, recipe: recipe, - recipeParameterValues: null, + recipeParameterValues: initialParameterValues, messages: [], }); } diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index f994e817..7348b213 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -181,7 +181,7 @@ if (process.platform !== 'darwin') { const recentDirs = loadRecentDirs(); const openDir = recentDirs.length > 0 ? recentDirs[0] : null; - const recipeDeeplink = parseRecipeDeeplink(protocolUrl); + const deeplinkData = parseRecipeDeeplink(protocolUrl); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); createChat( @@ -191,9 +191,10 @@ if (process.platform !== 'darwin') { undefined, undefined, undefined, - recipeDeeplink || undefined, + deeplinkData?.config, scheduledJobId || undefined, - undefined + undefined, + deeplinkData?.parameters ); }); return; // Skip the rest of the handler @@ -279,7 +280,7 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { } else if (parsedUrl.hostname === 'sessions') { window.webContents.send('open-shared-session', pendingDeepLink); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - const recipeDeeplink = parseRecipeDeeplink(parsedUrl.toString()); + const deeplinkData = parseRecipeDeeplink(pendingDeepLink ?? parsedUrl.toString()); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); // Create a new window and ignore the passed-in window @@ -290,9 +291,10 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { undefined, undefined, undefined, - recipeDeeplink || undefined, + deeplinkData?.config, scheduledJobId || undefined, - undefined + undefined, + deeplinkData?.parameters ); pendingDeepLink = null; } @@ -310,8 +312,8 @@ app.on('open-url', async (_event, url) => { console.log('[Main] Received open-url event:', url); if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { console.log('[Main] Detected bot/recipe URL, creating new chat window'); - let recipeDeeplink = parseRecipeDeeplink(url); - if (recipeDeeplink) { + const deeplinkData = parseRecipeDeeplink(url); + if (deeplinkData) { windowDeeplinkURL = url; } const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); @@ -324,9 +326,10 @@ app.on('open-url', async (_event, url) => { undefined, undefined, undefined, - recipeDeeplink || undefined, + deeplinkData?.config, scheduledJobId || undefined, - undefined + undefined, + deeplinkData?.parameters ); windowDeeplinkURL = null; return; // Skip the rest of the handler @@ -495,7 +498,8 @@ const createChat = async ( viewType?: string, recipeDeeplink?: string, // Raw deeplink decoded on server scheduledJobId?: string, // Scheduled job ID if applicable - recipeId?: string + recipeId?: string, + recipeParameters?: Record // Recipe parameter values from deeplink URL ) => { updateEnvironmentVariables(envToggles); @@ -544,6 +548,7 @@ const createChat = async ( GOOSE_VERSION: version, recipeId: recipeId, recipeDeeplink: recipeDeeplink, + recipeParameters: recipeParameters, scheduledJobId: scheduledJobId, }), ], @@ -1017,9 +1022,9 @@ const openDirectoryDialog = async (): Promise => { addRecentDir(dirToAdd); - let recipeDeeplink: string | undefined = undefined; + let deeplinkData: RecipeDeeplinkData | undefined = undefined; if (windowDeeplinkURL) { - recipeDeeplink = parseRecipeDeeplink(windowDeeplinkURL); + deeplinkData = parseRecipeDeeplink(windowDeeplinkURL); } // Create a new window with the selected directory await createChat( @@ -1029,14 +1034,21 @@ const openDirectoryDialog = async (): Promise => { undefined, undefined, undefined, + deeplinkData?.config, undefined, - recipeDeeplink + undefined, + deeplinkData?.parameters ); } return result; }; -function parseRecipeDeeplink(url: string): string | undefined { +interface RecipeDeeplinkData { + config: string; + parameters?: Record; +} + +function parseRecipeDeeplink(url: string): RecipeDeeplinkData | undefined { const parsedUrl = new URL(url); let recipeDeeplink = parsedUrl.searchParams.get('config'); if (recipeDeeplink && !url.includes(recipeDeeplink)) { @@ -1055,10 +1067,34 @@ function parseRecipeDeeplink(url: string): string | undefined { } } } - if (recipeDeeplink) { - return recipeDeeplink; + if (!recipeDeeplink) { + return undefined; } - return undefined; + + // Extract all query parameters except 'config' and 'scheduledJob' as recipe parameters + // Use raw query string parsing to preserve '+' characters (consistent with config handling) + const parameters: Record = {}; + const search = parsedUrl.search || ''; + const paramMatches = search.matchAll(/[?&]([^=&]+)=([^&]*)/g); + + for (const match of paramMatches) { + const key = match[1]; + const rawValue = match[2]; + + if (key !== 'config' && key !== 'scheduledJob') { + try { + parameters[key] = decodeURIComponent(rawValue); + } catch { + // If decoding fails, use raw value + parameters[key] = rawValue; + } + } + } + + return { + config: recipeDeeplink, + parameters: Object.keys(parameters).length > 0 ? parameters : undefined, + }; } // Global error handler