diff --git a/crates/goose-acp/src/server.rs b/crates/goose-acp/src/server.rs index 5552b09f03..c6b3ba38fd 100644 --- a/crates/goose-acp/src/server.rs +++ b/crates/goose-acp/src/server.rs @@ -255,8 +255,9 @@ async fn add_builtins(agent: &Agent, builtins: Vec) { let config = if PLATFORM_EXTENSIONS.contains_key(builtin.as_str()) { ExtensionConfig::Platform { name: builtin.clone(), - bundled: None, description: builtin.clone(), + display_name: None, + bundled: None, available_tools: Vec::new(), } } else { diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index 3bd1ad2dc8..3697bd6499 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -314,8 +314,9 @@ impl CliSession { if PLATFORM_EXTENSIONS.contains_key(extension_name) { ExtensionConfig::Platform { name: extension_name.to_string(), - bundled: None, description: extension_name.to_string(), + display_name: None, + bundled: None, available_tools: Vec::new(), } } else { diff --git a/crates/goose/src/agents/code_execution_extension.rs b/crates/goose/src/agents/code_execution_extension.rs index 93486ddee6..812bb5e148 100644 --- a/crates/goose/src/agents/code_execution_extension.rs +++ b/crates/goose/src/agents/code_execution_extension.rs @@ -434,7 +434,7 @@ impl CodeExecutionClient { }, server_info: Implementation { name: EXTENSION_NAME.to_string(), - title: Some("Code Execution".to_string()), + title: Some("Code Mode".to_string()), version: "1.0.0".to_string(), icons: None, website_url: None, diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index aebb4fddae..7ce3c1f4e5 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -48,6 +48,7 @@ pub static PLATFORM_EXTENSIONS: Lazy todo_extension::EXTENSION_NAME, PlatformExtensionDef { name: todo_extension::EXTENSION_NAME, + display_name: "Todo", description: "Enable a todo list for goose so it can keep track of what it is doing", default_enabled: true, @@ -59,6 +60,7 @@ pub static PLATFORM_EXTENSIONS: Lazy apps_extension::EXTENSION_NAME, PlatformExtensionDef { name: apps_extension::EXTENSION_NAME, + display_name: "Apps", description: "Create and manage custom Goose apps through chat. Apps are HTML/CSS/JavaScript and run in sandboxed windows.", default_enabled: true, @@ -70,6 +72,7 @@ pub static PLATFORM_EXTENSIONS: Lazy chatrecall_extension::EXTENSION_NAME, PlatformExtensionDef { name: chatrecall_extension::EXTENSION_NAME, + display_name: "Chat Recall", description: "Search past conversations and load session summaries for contextual memory", default_enabled: false, @@ -83,6 +86,7 @@ pub static PLATFORM_EXTENSIONS: Lazy "extensionmanager", PlatformExtensionDef { name: extension_manager_extension::EXTENSION_NAME, + display_name: "Extension Manager", description: "Enable extension management tools for discovering, enabling, and disabling extensions", default_enabled: true, @@ -94,6 +98,7 @@ pub static PLATFORM_EXTENSIONS: Lazy skills_extension::EXTENSION_NAME, PlatformExtensionDef { name: skills_extension::EXTENSION_NAME, + display_name: "Skills", description: "Load and use skills from relevant directories", default_enabled: true, client_factory: |ctx| Box::new(skills_extension::SkillsClient::new(ctx).unwrap()), @@ -104,7 +109,9 @@ pub static PLATFORM_EXTENSIONS: Lazy code_execution_extension::EXTENSION_NAME, PlatformExtensionDef { name: code_execution_extension::EXTENSION_NAME, - description: "Execute JavaScript code in a sandboxed environment", + display_name: "Code Mode", + description: + "Goose will make extension calls through code execution, saving tokens", default_enabled: false, client_factory: |ctx| { Box::new(code_execution_extension::CodeExecutionClient::new(ctx).unwrap()) @@ -158,6 +165,7 @@ impl PlatformExtensionContext { #[derive(Debug, Clone)] pub struct PlatformExtensionDef { pub name: &'static str, + pub display_name: &'static str, pub description: &'static str, pub default_enabled: bool, pub client_factory: fn(PlatformExtensionContext) -> Box, @@ -335,6 +343,7 @@ pub enum ExtensionConfig { #[serde(deserialize_with = "deserialize_null_with_default")] #[schema(required)] description: String, + display_name: Option, #[serde(default)] bundled: Option, #[serde(default)] diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index f053e22dd1..4cb55812c0 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -273,25 +273,34 @@ impl Config { } fn load(&self) -> Result { - if self.config_path.exists() { - self.load_values_with_recovery() + let mut values = if self.config_path.exists() { + self.load_values_with_recovery()? } else { // Config file doesn't exist, try to recover from backup first tracing::info!("Config file doesn't exist, attempting recovery from backup"); if let Ok(backup_values) = self.try_restore_from_backup() { tracing::info!("Successfully restored config from backup"); - return Ok(backup_values); + backup_values + } else { + // No backup available, create a default config + tracing::info!("No backup found, creating default configuration"); + + // Try to load from init-config.yaml if it exists, otherwise use empty config + let default_config = self.load_init_config_if_exists().unwrap_or_default(); + + self.create_and_save_default_config(default_config)? } + }; - // No backup available, create a default config - tracing::info!("No backup found, creating default configuration"); - - // Try to load from init-config.yaml if it exists, otherwise use empty config - let default_config = self.load_init_config_if_exists().unwrap_or_default(); - - self.create_and_save_default_config(default_config) + // Run migrations on the loaded config + if crate::config::migrations::run_migrations(&mut values) { + if let Err(e) = self.save_values(values.clone()) { + tracing::warn!("Failed to save migrated config: {}", e); + } } + + Ok(values) } pub fn all_values(&self) -> Result, ConfigError> { @@ -1203,13 +1212,7 @@ mod tests { // Print the final values for debugging println!("Final values: {:?}", final_values); - assert_eq!( - final_values.len(), - 3, - "Expected 3 values, got {}", - final_values.len() - ); - + // Check that our 3 keys are present (migrations may add additional keys like "extensions") for i in 0..3 { let key = format!("key{}", i); let value = format!("value{}", i); @@ -1285,19 +1288,22 @@ mod tests { // Try to load values - should create a fresh default config let recovered_values = config.all_values()?; - // Should return empty config - assert_eq!(recovered_values.len(), 0); + // Note: migrations may add keys like "extensions", so we just verify + // that no user-defined keys exist (the config was reset) + assert!( + !recovered_values.contains_key("key1"), + "Should not have user keys after recovery" + ); // Verify that a clean config file was written to disk let file_content = std::fs::read_to_string(config_file.path())?; - // Should be valid YAML (empty object) + // Should be valid YAML let parsed: serde_yaml::Value = serde_yaml::from_str(&file_content)?; assert!(parsed.is_mapping()); // Should be able to load it again without issues - let reloaded_values = config.all_values()?; - assert_eq!(reloaded_values.len(), 0); + let _reloaded_values = config.all_values()?; Ok(()) } @@ -1316,8 +1322,12 @@ mod tests { // Try to load values - should create a fresh default config file let values = config.all_values()?; - // Should return empty config - assert_eq!(values.len(), 0); + // Note: migrations may add keys like "extensions", so we just verify + // that no user-defined keys exist (the config was freshly created) + assert!( + !values.contains_key("key1"), + "Should not have user keys in fresh config" + ); // Verify that the config file was created assert!(config_path.exists()); @@ -1328,8 +1338,7 @@ mod tests { assert!(parsed.is_mapping()); // Should be able to load it again without issues - let reloaded_values = config.all_values()?; - assert_eq!(reloaded_values.len(), 0); + let _reloaded_values = config.all_values()?; Ok(()) } diff --git a/crates/goose/src/config/extensions.rs b/crates/goose/src/config/extensions.rs index c3f4b6cd3e..aa8c9b0e23 100644 --- a/crates/goose/src/config/extensions.rs +++ b/crates/goose/src/config/extensions.rs @@ -1,5 +1,4 @@ use super::base::Config; -use crate::agents::extension::PLATFORM_EXTENSIONS; use crate::agents::ExtensionConfig; use indexmap::IndexMap; use serde::{Deserialize, Serialize}; @@ -54,24 +53,6 @@ fn get_extensions_map() -> IndexMap { } } - // Always inject platform extensions (code_execution, todo, skills, etc.) - // These are internal agent extensions that should always be available - for (name, def) in PLATFORM_EXTENSIONS.iter() { - if !extensions_map.contains_key(*name) { - extensions_map.insert( - name.to_string(), - ExtensionEntry { - config: ExtensionConfig::Platform { - name: def.name.to_string(), - description: def.description.to_string(), - bundled: Some(true), - available_tools: Vec::new(), - }, - enabled: def.default_enabled, - }, - ); - } - } extensions_map } diff --git a/crates/goose/src/config/migrations.rs b/crates/goose/src/config/migrations.rs new file mode 100644 index 0000000000..4c37757211 --- /dev/null +++ b/crates/goose/src/config/migrations.rs @@ -0,0 +1,141 @@ +use crate::agents::extension::PLATFORM_EXTENSIONS; +use crate::agents::ExtensionConfig; +use crate::config::extensions::ExtensionEntry; +use serde_yaml::Mapping; + +const EXTENSIONS_CONFIG_KEY: &str = "extensions"; + +pub fn run_migrations(config: &mut Mapping) -> bool { + let mut changed = false; + changed |= migrate_platform_extensions(config); + changed +} + +fn migrate_platform_extensions(config: &mut Mapping) -> bool { + let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); + + let extensions_value = config + .get(&extensions_key) + .cloned() + .unwrap_or(serde_yaml::Value::Mapping(Mapping::new())); + + let mut extensions_map: Mapping = match extensions_value { + serde_yaml::Value::Mapping(m) => m, + _ => Mapping::new(), + }; + + let mut needs_save = false; + + for (name, def) in PLATFORM_EXTENSIONS.iter() { + let ext_key = serde_yaml::Value::String(name.to_string()); + let existing = extensions_map.get(&ext_key); + + let needs_migration = match existing { + None => true, + Some(value) => match serde_yaml::from_value::(value.clone()) { + Ok(entry) => { + if let ExtensionConfig::Platform { + description, + display_name, + .. + } = &entry.config + { + description != def.description + || display_name.as_deref() != Some(def.display_name) + } else { + true + } + } + Err(_) => true, + }, + }; + + if needs_migration { + let enabled = existing + .and_then(|v| serde_yaml::from_value::(v.clone()).ok()) + .map(|e| e.enabled) + .unwrap_or(def.default_enabled); + + let new_entry = ExtensionEntry { + config: ExtensionConfig::Platform { + name: def.name.to_string(), + description: def.description.to_string(), + display_name: Some(def.display_name.to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }, + enabled, + }; + + if let Ok(value) = serde_yaml::to_value(&new_entry) { + extensions_map.insert(ext_key, value); + needs_save = true; + } + } + } + + if needs_save { + config.insert(extensions_key, serde_yaml::Value::Mapping(extensions_map)); + } + + needs_save +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_migrate_platform_extensions_empty_config() { + let mut config = Mapping::new(); + let changed = run_migrations(&mut config); + + assert!(changed); + let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); + assert!(config.contains_key(&extensions_key)); + } + + #[test] + fn test_migrate_platform_extensions_preserves_enabled_state() { + let mut config = Mapping::new(); + let mut extensions = Mapping::new(); + let todo_entry = ExtensionEntry { + config: ExtensionConfig::Platform { + name: "todo".to_string(), + description: "old description".to_string(), + display_name: Some("Old Name".to_string()), + bundled: Some(true), + available_tools: Vec::new(), + }, + enabled: false, + }; + extensions.insert( + serde_yaml::Value::String("todo".to_string()), + serde_yaml::to_value(&todo_entry).unwrap(), + ); + config.insert( + serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()), + serde_yaml::Value::Mapping(extensions), + ); + + let changed = run_migrations(&mut config); + assert!(changed); + + let extensions_key = serde_yaml::Value::String(EXTENSIONS_CONFIG_KEY.to_string()); + let extensions = config.get(&extensions_key).unwrap().as_mapping().unwrap(); + let todo_key = serde_yaml::Value::String("todo".to_string()); + let todo_value = extensions.get(&todo_key).unwrap(); + let todo_entry: ExtensionEntry = serde_yaml::from_value(todo_value.clone()).unwrap(); + + assert!(!todo_entry.enabled); + } + + #[test] + fn test_migrate_platform_extensions_idempotent() { + let mut config = Mapping::new(); + run_migrations(&mut config); + + let changed = run_migrations(&mut config); + assert!(!changed); + } +} diff --git a/crates/goose/src/config/mod.rs b/crates/goose/src/config/mod.rs index 0599cfb8e4..e73349745d 100644 --- a/crates/goose/src/config/mod.rs +++ b/crates/goose/src/config/mod.rs @@ -3,6 +3,7 @@ pub mod declarative_providers; mod experiments; pub mod extensions; pub mod goose_mode; +mod migrations; pub mod paths; pub mod permission; pub mod search_path; diff --git a/crates/goose/src/recipe/recipe_extension_adapter.rs b/crates/goose/src/recipe/recipe_extension_adapter.rs index 32e680a699..3b32c3e35d 100644 --- a/crates/goose/src/recipe/recipe_extension_adapter.rs +++ b/crates/goose/src/recipe/recipe_extension_adapter.rs @@ -42,6 +42,8 @@ enum RecipeExtensionConfigInternal { #[serde(default)] description: Option, #[serde(default)] + display_name: Option, + #[serde(default)] bundled: Option, #[serde(default)] available_tools: Vec, @@ -128,6 +130,7 @@ impl From for ExtensionConfig { available_tools }, Platform { + display_name, bundled, available_tools }, diff --git a/crates/goose/tests/agent.rs b/crates/goose/tests/agent.rs index ab7374fcc9..ee6493d788 100644 --- a/crates/goose/tests/agent.rs +++ b/crates/goose/tests/agent.rs @@ -512,6 +512,7 @@ mod tests { description: "Enable a todo list for goose so it can keep track of what it is doing" .to_string(), + display_name: Some("Todo".to_string()), bundled: Some(true), available_tools: vec![], }, @@ -535,6 +536,7 @@ mod tests { let ext_config = ExtensionConfig::Platform { name: "extensionmanager".to_string(), description: "Extension Manager".to_string(), + display_name: Some("Extension Manager".to_string()), bundled: Some(true), available_tools: vec![], }; diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 1d73a0e13d..13e5958399 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -3813,6 +3813,10 @@ "description": { "type": "string" }, + "display_name": { + "type": "string", + "nullable": true + }, "name": { "type": "string", "description": "The name used to identify this extension" diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index c832673b9d..2bc51d6010 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -253,6 +253,7 @@ export type ExtensionConfig = { available_tools?: Array; bundled?: boolean | null; description: string; + display_name?: string | null; /** * The name used to identify this extension */ diff --git a/ui/desktop/src/components/schedule/ScheduleModal.tsx b/ui/desktop/src/components/schedule/ScheduleModal.tsx index ef5a1bae60..fccda47b1f 100644 --- a/ui/desktop/src/components/schedule/ScheduleModal.tsx +++ b/ui/desktop/src/components/schedule/ScheduleModal.tsx @@ -30,7 +30,7 @@ type SourceType = 'file' | 'deeplink'; interface CleanExtension { name: string; - type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http'; + type: 'stdio' | 'sse' | 'builtin' | 'frontend' | 'streamable_http' | 'platform'; cmd?: string; args?: string[]; uri?: string; @@ -120,7 +120,7 @@ function recipeToYaml(recipe: Recipe): string { if (extAny.args) { cleanExt.args = extAny.args as string[]; } - } else if (ext.type === 'builtin' && extAny.display_name) { + } else if ((ext.type === 'builtin' || ext.type === 'platform') && extAny.display_name) { cleanExt.display_name = extAny.display_name as string; } diff --git a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx index a4e526a336..d42b94baa0 100644 --- a/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx +++ b/ui/desktop/src/components/settings/extensions/subcomponents/ExtensionList.tsx @@ -104,7 +104,9 @@ export function formatExtensionName(name: string): string { } export function getFriendlyTitle(extension: FixedExtensionEntry): string { - const name = (extension.type === 'builtin' && extension.display_name) || extension.name; + const name = + ((extension.type === 'builtin' || extension.type === 'platform') && extension.display_name) || + extension.name; return formatExtensionName(name); }