diff --git a/crates/goose-sdk/src/custom_requests.rs b/crates/goose-sdk/src/custom_requests.rs index ed2b4c11b0..d77521bb66 100644 --- a/crates/goose-sdk/src/custom_requests.rs +++ b/crates/goose-sdk/src/custom_requests.rs @@ -237,6 +237,92 @@ pub struct DefaultsReadResponse { pub model_id: Option, } +/// Save Goose default provider and model configuration. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request(method = "_goose/defaults/save", response = DefaultsReadResponse)] +#[serde(rename_all = "camelCase")] +pub struct DefaultsSaveRequest { + pub provider_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_id: Option, +} + +/// Sources that onboarding knows how to discover and import. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum OnboardingImportSourceKind { + #[default] + GooseConfig, + ClaudeDesktop, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportCounts { + pub providers: u32, + pub extensions: u32, + pub sessions: u32, + pub skills: u32, + pub projects: u32, + pub preferences: u32, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportCandidate { + pub id: String, + pub source_kind: OnboardingImportSourceKind, + pub display_name: String, + pub path: String, + pub counts: OnboardingImportCounts, + #[serde(default)] + pub warnings: Vec, +} + +/// Scan for existing Goose and compatible app data that onboarding can import. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/onboarding/import/scan", + response = OnboardingImportScanResponse +)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportScanRequest { + /// Empty means all supported import sources. + #[serde(default)] + pub sources: Vec, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportScanResponse { + pub candidates: Vec, +} + +/// Import selected onboarding candidates. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/onboarding/import/apply", + response = OnboardingImportApplyResponse +)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportApplyRequest { + #[serde(default)] + pub candidate_ids: Vec, + #[serde(default)] + pub enable_imported_extensions: bool, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] +#[serde(rename_all = "camelCase")] +pub struct OnboardingImportApplyResponse { + pub imported: OnboardingImportCounts, + pub skipped: OnboardingImportCounts, + #[serde(default)] + pub warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider_defaults: Option, +} + /// Set a dictation provider secret value. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/dictation/secret/save", response = EmptyResponse)] @@ -418,6 +504,17 @@ pub struct ProviderConfigDeleteRequest { pub provider_id: String, } +/// Run a provider-owned native authentication flow and start an inventory refresh when supported. +#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] +#[request( + method = "_goose/providers/config/authenticate", + response = ProviderConfigChangeResponse +)] +#[serde(rename_all = "camelCase")] +pub struct ProviderConfigAuthenticateRequest { + pub provider_id: String, +} + #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] #[serde(rename_all = "camelCase")] pub struct ProviderConfigChangeResponse { diff --git a/crates/goose/acp-meta.json b/crates/goose/acp-meta.json index 619e85196b..1f78227fd3 100644 --- a/crates/goose/acp-meta.json +++ b/crates/goose/acp-meta.json @@ -125,6 +125,11 @@ "requestType": "ProviderConfigDeleteRequest", "responseType": "ProviderConfigChangeResponse" }, + { + "method": "_goose/providers/config/authenticate", + "requestType": "ProviderConfigAuthenticateRequest", + "responseType": "ProviderConfigChangeResponse" + }, { "method": "_goose/preferences/read", "requestType": "PreferencesReadRequest", @@ -145,6 +150,21 @@ "requestType": "DefaultsReadRequest", "responseType": "DefaultsReadResponse" }, + { + "method": "_goose/defaults/save", + "requestType": "DefaultsSaveRequest", + "responseType": "DefaultsReadResponse" + }, + { + "method": "_goose/onboarding/import/scan", + "requestType": "OnboardingImportScanRequest", + "responseType": "OnboardingImportScanResponse" + }, + { + "method": "_goose/onboarding/import/apply", + "requestType": "OnboardingImportApplyRequest", + "responseType": "OnboardingImportApplyResponse" + }, { "method": "_goose/session/export", "requestType": "ExportSessionRequest", diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index c7d9da61ed..cd25c04c7d 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -1415,6 +1415,20 @@ "x-side": "agent", "x-method": "_goose/providers/config/delete" }, + "ProviderConfigAuthenticateRequest": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + } + }, + "required": [ + "providerId" + ], + "description": "Run a provider-owned native authentication flow and start an inventory refresh when supported.", + "x-side": "agent", + "x-method": "_goose/providers/config/authenticate" + }, "PreferencesReadRequest": { "type": "object", "properties": { @@ -1521,8 +1535,191 @@ ] } }, + "x-side": "agent" + }, + "DefaultsSaveRequest": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "modelId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "providerId" + ], + "description": "Save Goose default provider and model configuration.", "x-side": "agent", - "x-method": "_goose/defaults/read" + "x-method": "_goose/defaults/save" + }, + "OnboardingImportScanRequest": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "$ref": "#/$defs/OnboardingImportSourceKind" + }, + "description": "Empty means all supported import sources.", + "default": [] + } + }, + "description": "Scan for existing Goose and compatible app data that onboarding can import.", + "x-side": "agent", + "x-method": "_goose/onboarding/import/scan" + }, + "OnboardingImportSourceKind": { + "type": "string", + "enum": [ + "goose_config", + "claude_desktop" + ], + "description": "Sources that onboarding knows how to discover and import." + }, + "OnboardingImportScanResponse": { + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "$ref": "#/$defs/OnboardingImportCandidate" + } + } + }, + "required": [ + "candidates" + ], + "x-side": "agent", + "x-method": "_goose/onboarding/import/scan" + }, + "OnboardingImportCandidate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "sourceKind": { + "$ref": "#/$defs/OnboardingImportSourceKind" + }, + "displayName": { + "type": "string" + }, + "path": { + "type": "string" + }, + "counts": { + "$ref": "#/$defs/OnboardingImportCounts" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + } + }, + "required": [ + "id", + "sourceKind", + "displayName", + "path", + "counts" + ] + }, + "OnboardingImportCounts": { + "type": "object", + "properties": { + "providers": { + "type": "integer", + "minimum": 0 + }, + "extensions": { + "type": "integer", + "minimum": 0 + }, + "sessions": { + "type": "integer", + "minimum": 0 + }, + "skills": { + "type": "integer", + "minimum": 0 + }, + "projects": { + "type": "integer", + "minimum": 0 + }, + "preferences": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "providers", + "extensions", + "sessions", + "skills", + "projects", + "preferences" + ] + }, + "OnboardingImportApplyRequest": { + "type": "object", + "properties": { + "candidateIds": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "enableImportedExtensions": { + "type": "boolean", + "default": false + } + }, + "description": "Import selected onboarding candidates.", + "x-side": "agent", + "x-method": "_goose/onboarding/import/apply" + }, + "OnboardingImportApplyResponse": { + "type": "object", + "properties": { + "imported": { + "$ref": "#/$defs/OnboardingImportCounts" + }, + "skipped": { + "$ref": "#/$defs/OnboardingImportCounts" + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "default": [] + }, + "providerDefaults": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultsReadResponse" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "imported", + "skipped" + ], + "x-side": "agent", + "x-method": "_goose/onboarding/import/apply" }, "ExportSessionRequest": { "type": "object", @@ -2533,6 +2730,15 @@ "description": "Params for _goose/providers/config/delete", "title": "ProviderConfigDeleteRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/ProviderConfigAuthenticateRequest" + } + ], + "description": "Params for _goose/providers/config/authenticate", + "title": "ProviderConfigAuthenticateRequest" + }, { "allOf": [ { @@ -2569,6 +2775,33 @@ "description": "Params for _goose/defaults/read", "title": "DefaultsReadRequest" }, + { + "allOf": [ + { + "$ref": "#/$defs/DefaultsSaveRequest" + } + ], + "description": "Params for _goose/defaults/save", + "title": "DefaultsSaveRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/OnboardingImportScanRequest" + } + ], + "description": "Params for _goose/onboarding/import/scan", + "title": "OnboardingImportScanRequest" + }, + { + "allOf": [ + { + "$ref": "#/$defs/OnboardingImportApplyRequest" + } + ], + "description": "Params for _goose/onboarding/import/apply", + "title": "OnboardingImportApplyRequest" + }, { "allOf": [ { @@ -2957,6 +3190,22 @@ ], "title": "DefaultsReadResponse" }, + { + "allOf": [ + { + "$ref": "#/$defs/OnboardingImportScanResponse" + } + ], + "title": "OnboardingImportScanResponse" + }, + { + "allOf": [ + { + "$ref": "#/$defs/OnboardingImportApplyResponse" + } + ], + "title": "OnboardingImportApplyResponse" + }, { "allOf": [ { diff --git a/crates/goose/src/acp/server.rs b/crates/goose/src/acp/server.rs index 55aa44a23e..0995243554 100644 --- a/crates/goose/src/acp/server.rs +++ b/crates/goose/src/acp/server.rs @@ -72,6 +72,7 @@ mod custom_dispatch; mod dictation; mod dispatch; mod extensions; +mod onboarding; mod providers; mod resources; mod sessions; diff --git a/crates/goose/src/acp/server/config.rs b/crates/goose/src/acp/server/config.rs index 3c2684e122..2dacf9ece2 100644 --- a/crates/goose/src/acp/server/config.rs +++ b/crates/goose/src/acp/server/config.rs @@ -65,6 +65,72 @@ impl GooseAcpAgent { model_id: optional_config_string(&config, "GOOSE_MODEL")?, }) } + + pub(super) async fn on_defaults_save( + &self, + req: DefaultsSaveRequest, + ) -> Result { + let provider_id = req.provider_id.trim().to_string(); + if provider_id.is_empty() { + return Err(sacp::Error::invalid_params().data("providerId cannot be empty")); + } + + let model_id = req.model_id.and_then(|model| { + let model = model.trim().to_string(); + (!model.is_empty()).then_some(model) + }); + + let entries = self + .provider_inventory + .entries(std::slice::from_ref(&provider_id)) + .await + .internal_err_ctx("Failed to read provider inventory")?; + let Some(entry) = entries + .into_iter() + .find(|entry| entry.provider_id == provider_id) + else { + return Err( + sacp::Error::invalid_params().data(format!("Unknown provider: {provider_id}")) + ); + }; + + if !entry.configured { + return Err(sacp::Error::invalid_params() + .data(format!("Provider is not configured: {provider_id}"))); + } + + if let Some(model_id) = model_id.as_deref() { + let model_exists = entry.default_model == model_id + || entry.models.iter().any(|model| model.id == model_id); + if !model_exists { + return Err(sacp::Error::invalid_params().data(format!( + "Model '{model_id}' is not available for provider '{provider_id}'" + ))); + } + } + + let config = self.config()?; + config + .set_param_values(&[( + "GOOSE_PROVIDER".to_string(), + serde_json::Value::String(provider_id.clone()), + )]) + .internal_err_ctx("Failed to save default provider")?; + if let Some(model_id) = model_id.as_deref() { + config + .set_param("GOOSE_MODEL", model_id) + .internal_err_ctx("Failed to save default model")?; + } else { + config + .delete("GOOSE_MODEL") + .internal_err_ctx("Failed to clear default model")?; + } + + Ok(DefaultsReadResponse { + provider_id: Some(provider_id), + model_id, + }) + } } struct PreferenceDef { diff --git a/crates/goose/src/acp/server/custom_dispatch.rs b/crates/goose/src/acp/server/custom_dispatch.rs index ac1deca8f4..1a75e48028 100644 --- a/crates/goose/src/acp/server/custom_dispatch.rs +++ b/crates/goose/src/acp/server/custom_dispatch.rs @@ -208,6 +208,14 @@ impl GooseAcpAgent { self.on_delete_provider_config(req).await } + #[custom_method(ProviderConfigAuthenticateRequest)] + async fn dispatch_authenticate_provider_config( + &self, + req: ProviderConfigAuthenticateRequest, + ) -> Result { + self.on_authenticate_provider_config(req).await + } + #[custom_method(PreferencesReadRequest)] async fn dispatch_preferences_read( &self, @@ -240,6 +248,30 @@ impl GooseAcpAgent { self.on_defaults_read(req).await } + #[custom_method(DefaultsSaveRequest)] + async fn dispatch_defaults_save( + &self, + req: DefaultsSaveRequest, + ) -> Result { + self.on_defaults_save(req).await + } + + #[custom_method(OnboardingImportScanRequest)] + async fn dispatch_onboarding_import_scan( + &self, + req: OnboardingImportScanRequest, + ) -> Result { + self.on_onboarding_import_scan(req).await + } + + #[custom_method(OnboardingImportApplyRequest)] + async fn dispatch_onboarding_import_apply( + &self, + req: OnboardingImportApplyRequest, + ) -> Result { + self.on_onboarding_import_apply(req).await + } + #[custom_method(ExportSessionRequest)] async fn dispatch_export_session( &self, diff --git a/crates/goose/src/acp/server/onboarding.rs b/crates/goose/src/acp/server/onboarding.rs new file mode 100644 index 0000000000..1729fa631a --- /dev/null +++ b/crates/goose/src/acp/server/onboarding.rs @@ -0,0 +1,695 @@ +use super::*; +use crate::config::extensions::name_to_key; +use serde::Deserialize; +use serde_yaml::Mapping; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +const GOOSE_CONFIG_PREFIX: &str = "goose_config:"; +const CLAUDE_DESKTOP_PREFIX: &str = "claude_desktop:"; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ClaudeDesktopConfig { + #[serde(default)] + mcp_servers: HashMap, +} + +#[derive(Debug, Deserialize)] +struct ClaudeMcpServer { + #[serde(default)] + command: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +impl GooseAcpAgent { + pub(super) async fn on_onboarding_import_scan( + &self, + req: OnboardingImportScanRequest, + ) -> Result { + let source_filter = source_filter(&req.sources); + let mut candidates = Vec::new(); + + if source_filter.contains(&OnboardingImportSourceKind::GooseConfig) { + for path in goose_config_candidate_paths(&self.config_dir) { + if let Some(candidate) = scan_goose_config_candidate(&path) { + candidates.push(candidate); + } + } + } + + if source_filter.contains(&OnboardingImportSourceKind::ClaudeDesktop) { + for path in claude_desktop_candidate_paths() { + if let Some(candidate) = scan_claude_desktop_candidate(&path) { + candidates.push(candidate); + } + } + } + + candidates.sort_by(|a, b| a.display_name.cmp(&b.display_name)); + Ok(OnboardingImportScanResponse { candidates }) + } + + pub(super) async fn on_onboarding_import_apply( + &self, + req: OnboardingImportApplyRequest, + ) -> Result { + let config = self.config()?; + Ok(apply_onboarding_import_candidates( + &config, + &self.config_dir, + &req, + )) + } +} + +fn apply_onboarding_import_candidates( + config: &Config, + target_config_dir: &Path, + req: &OnboardingImportApplyRequest, +) -> OnboardingImportApplyResponse { + let mut imported = OnboardingImportCounts::default(); + let mut skipped = OnboardingImportCounts::default(); + let mut warnings = Vec::new(); + let mut provider_defaults = None; + + for candidate_id in &req.candidate_ids { + match parse_candidate_id(candidate_id) { + Some((OnboardingImportSourceKind::GooseConfig, path)) => { + match apply_goose_config_candidate(config, target_config_dir, &path) { + Ok(result) => { + add_counts(&mut imported, &result.imported); + add_counts(&mut skipped, &result.skipped); + warnings.extend(result.warnings); + if result.provider_defaults.provider_id.is_some() + || result.provider_defaults.model_id.is_some() + { + provider_defaults = Some(result.provider_defaults); + } + } + Err(error) => warnings.push(import_failure_warning( + OnboardingImportSourceKind::GooseConfig, + &path, + &error, + )), + } + } + Some((OnboardingImportSourceKind::ClaudeDesktop, path)) => { + match apply_claude_desktop_candidate(config, &path, req.enable_imported_extensions) + { + Ok(result) => { + add_counts(&mut imported, &result.imported); + add_counts(&mut skipped, &result.skipped); + warnings.extend(result.warnings); + } + Err(error) => warnings.push(import_failure_warning( + OnboardingImportSourceKind::ClaudeDesktop, + &path, + &error, + )), + } + } + None => warnings.push(format!("Skipped unknown import candidate: {candidate_id}")), + } + } + + OnboardingImportApplyResponse { + imported, + skipped, + warnings, + provider_defaults, + } +} + +#[derive(Default)] +struct ApplyResult { + imported: OnboardingImportCounts, + skipped: OnboardingImportCounts, + warnings: Vec, + provider_defaults: DefaultsReadResponse, +} + +fn source_filter(sources: &[OnboardingImportSourceKind]) -> HashSet { + if sources.is_empty() { + return [ + OnboardingImportSourceKind::GooseConfig, + OnboardingImportSourceKind::ClaudeDesktop, + ] + .into_iter() + .collect(); + } + + sources.iter().copied().collect() +} + +fn add_counts(target: &mut OnboardingImportCounts, source: &OnboardingImportCounts) { + target.providers += source.providers; + target.extensions += source.extensions; + target.sessions += source.sessions; + target.skills += source.skills; + target.projects += source.projects; + target.preferences += source.preferences; +} + +fn import_failure_warning( + source_kind: OnboardingImportSourceKind, + path: &Path, + error: &anyhow::Error, +) -> String { + let source_name = match source_kind { + OnboardingImportSourceKind::GooseConfig => "Goose configuration", + OnboardingImportSourceKind::ClaudeDesktop => "Claude Desktop tools", + }; + format!( + "Skipped {source_name} import at {}: {error}", + path.display() + ) +} + +fn goose_config_candidate_paths(config_dir: &Path) -> Vec { + let mut paths = vec![config_dir.join(CONFIG_YAML_NAME)]; + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".config").join("goose").join(CONFIG_YAML_NAME)); + } + dedupe_paths(paths) +} + +fn claude_desktop_candidate_paths() -> Vec { + let mut paths = Vec::new(); + if let Some(config_dir) = dirs::config_dir() { + paths.push(config_dir.join("Claude").join("claude_desktop_config.json")); + } + if let Some(home) = dirs::home_dir() { + paths.push( + home.join("Library") + .join("Application Support") + .join("Claude") + .join("claude_desktop_config.json"), + ); + paths.push( + home.join("AppData") + .join("Roaming") + .join("Claude") + .join("claude_desktop_config.json"), + ); + } + dedupe_paths(paths) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut seen = HashSet::new(); + paths + .into_iter() + .filter(|path| seen.insert(path.clone())) + .collect() +} + +fn scan_goose_config_candidate(path: &Path) -> Option { + if !path.exists() { + return None; + } + + let mapping = read_yaml_mapping(path).ok()?; + let mut counts = OnboardingImportCounts::default(); + let mut warnings = Vec::new(); + + if mapping_contains_string(&mapping, "GOOSE_PROVIDER") + || mapping_contains_string(&mapping, "GOOSE_MODEL") + { + counts.providers = 1; + } + counts.extensions = extension_count(&mapping); + counts.skills = path + .parent() + .map(|dir| count_skill_dirs(&dir.join("skills"))) + .unwrap_or_default(); + + if counts.sessions > 0 { + warnings.push("Sessions are already shared through Goose's data store.".to_string()); + } + + Some(OnboardingImportCandidate { + id: candidate_id(GOOSE_CONFIG_PREFIX, path), + source_kind: OnboardingImportSourceKind::GooseConfig, + display_name: "Existing Goose configuration".to_string(), + path: path.to_string_lossy().to_string(), + counts, + warnings, + }) +} + +fn scan_claude_desktop_candidate(path: &Path) -> Option { + if !path.exists() { + return None; + } + + let (servers, warnings) = read_claude_servers(path).ok()?; + if servers.is_empty() && warnings.is_empty() { + return None; + } + + Some(OnboardingImportCandidate { + id: candidate_id(CLAUDE_DESKTOP_PREFIX, path), + source_kind: OnboardingImportSourceKind::ClaudeDesktop, + display_name: "Claude Desktop tools".to_string(), + path: path.to_string_lossy().to_string(), + counts: OnboardingImportCounts { + extensions: servers.len() as u32, + ..Default::default() + }, + warnings, + }) +} + +fn candidate_id(prefix: &str, path: &Path) -> String { + format!("{prefix}{}", path.to_string_lossy()) +} + +fn parse_candidate_id(id: &str) -> Option<(OnboardingImportSourceKind, PathBuf)> { + if let Some(path) = id.strip_prefix(GOOSE_CONFIG_PREFIX) { + return Some((OnboardingImportSourceKind::GooseConfig, PathBuf::from(path))); + } + if let Some(path) = id.strip_prefix(CLAUDE_DESKTOP_PREFIX) { + return Some(( + OnboardingImportSourceKind::ClaudeDesktop, + PathBuf::from(path), + )); + } + None +} + +fn read_yaml_mapping(path: &Path) -> anyhow::Result { + let content = fs::read_to_string(path)?; + let value: serde_yaml::Value = serde_yaml::from_str(&content)?; + Ok(value.as_mapping().cloned().unwrap_or_default()) +} + +fn mapping_contains_string(mapping: &Mapping, key: &str) -> bool { + mapping + .get(serde_yaml::Value::String(key.to_string())) + .and_then(|value| value.as_str()) + .is_some_and(|value| !value.trim().is_empty()) +} + +fn extension_count(mapping: &Mapping) -> u32 { + mapping + .get(serde_yaml::Value::String("extensions".to_string())) + .and_then(|value| value.as_mapping()) + .map(|extensions| extensions.len() as u32) + .unwrap_or_default() +} + +fn count_skill_dirs(path: &Path) -> u32 { + let Ok(entries) = fs::read_dir(path) else { + return 0; + }; + + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir() && path.join("SKILL.md").exists()) + .count() as u32 +} + +fn apply_goose_config_candidate( + target_config: &Config, + target_config_dir: &Path, + source_path: &Path, +) -> anyhow::Result { + let source = read_yaml_mapping(source_path)?; + let mut result = ApplyResult::default(); + + let provider = yaml_string(&source, "GOOSE_PROVIDER"); + let model = yaml_string(&source, "GOOSE_MODEL"); + if provider.is_some() || model.is_some() { + let mut updates = Vec::new(); + if let Some(provider) = provider.clone() { + updates.push(( + "GOOSE_PROVIDER".to_string(), + serde_json::Value::String(provider), + )); + } + if let Some(model) = model.clone() { + updates.push(("GOOSE_MODEL".to_string(), serde_json::Value::String(model))); + } + target_config.set_param_values(&updates)?; + result.provider_defaults = DefaultsReadResponse { + provider_id: provider, + model_id: model, + }; + result.imported.providers = 1; + } + + let extension_result = import_goose_config_extensions(target_config, &source)?; + result.imported.extensions += extension_result.imported; + result.skipped.extensions += extension_result.skipped; + + if let Some(source_dir) = source_path.parent() { + let secrets_result = import_file_secrets(target_config, &source_dir.join("secrets.yaml"))?; + if secrets_result > 0 && result.imported.providers == 0 { + result.imported.providers = 1; + } + + let skills_result = import_skill_dirs( + &source_dir.join("skills"), + &target_config_dir.join("skills"), + )?; + result.imported.skills += skills_result.imported; + result.skipped.skills += skills_result.skipped; + } + + result + .warnings + .push("Session history already lives in the Goose data store when available.".to_string()); + Ok(result) +} + +fn yaml_string(mapping: &Mapping, key: &str) -> Option { + mapping + .get(serde_yaml::Value::String(key.to_string())) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +#[derive(Default)] +struct ImportPairCount { + imported: u32, + skipped: u32, +} + +fn import_goose_config_extensions( + target_config: &Config, + source: &Mapping, +) -> anyhow::Result { + let Some(source_extensions) = source + .get(serde_yaml::Value::String("extensions".to_string())) + .and_then(|value| value.as_mapping()) + else { + return Ok(ImportPairCount::default()); + }; + + let mut target_extensions = load_target_extensions(target_config)?; + let mut counts = ImportPairCount::default(); + for (key, value) in source_extensions { + if target_extensions.contains_key(key) { + counts.skipped += 1; + continue; + } + target_extensions.insert(key.clone(), value.clone()); + counts.imported += 1; + } + + if counts.imported > 0 { + target_config.set_param("extensions", target_extensions)?; + } + + Ok(counts) +} + +fn load_target_extensions(target_config: &Config) -> anyhow::Result { + match target_config.get_param::("extensions") { + Ok(extensions) => Ok(extensions), + Err(crate::config::ConfigError::NotFound(_)) => Ok(Mapping::new()), + Err(error) => Err(error.into()), + } +} + +fn import_file_secrets(target_config: &Config, source_path: &Path) -> anyhow::Result { + if !source_path.exists() { + return Ok(0); + } + + let mapping = read_yaml_mapping(source_path)?; + let mut updates = Vec::new(); + for (key, value) in mapping { + let Some(key) = key.as_str() else { + continue; + }; + updates.push((key.to_string(), serde_json::to_value(value)?)); + } + target_config.set_secret_values(&updates)?; + Ok(updates.len() as u32) +} + +fn import_skill_dirs(source_root: &Path, target_root: &Path) -> anyhow::Result { + let Ok(entries) = fs::read_dir(source_root) else { + return Ok(ImportPairCount::default()); + }; + + let mut counts = ImportPairCount::default(); + fs::create_dir_all(target_root)?; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() || file_type.is_symlink() { + continue; + } + let source = entry.path(); + if !source.join("SKILL.md").exists() { + continue; + } + let Some(name) = source.file_name() else { + continue; + }; + let target = target_root.join(name); + if target.exists() { + counts.skipped += 1; + continue; + } + copy_dir_recursively(&source, &target)?; + counts.imported += 1; + } + Ok(counts) +} + +fn copy_dir_recursively(source: &Path, target: &Path) -> anyhow::Result<()> { + fs::create_dir_all(target)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let target_path = target.join(entry.file_name()); + let file_type = fs::symlink_metadata(&source_path)?.file_type(); + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + copy_dir_recursively(&source_path, &target_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &target_path)?; + } + } + Ok(()) +} + +fn apply_claude_desktop_candidate( + target_config: &Config, + source_path: &Path, + enable_imported_extensions: bool, +) -> anyhow::Result { + let (servers, mut warnings) = read_claude_servers(source_path)?; + let mut target_extensions = load_target_extensions(target_config)?; + let mut result = ApplyResult::default(); + + for (name, server) in servers { + let command = match server.command { + Some(command) if !command.trim().is_empty() => command, + _ => { + result.skipped.extensions += 1; + warnings.push(format!( + "Skipped Claude MCP server '{name}' because it has no command." + )); + continue; + } + }; + + let key = serde_yaml::Value::String(name_to_key(&name)); + if target_extensions.contains_key(&key) { + result.skipped.extensions += 1; + continue; + } + + let entry = crate::config::extensions::ExtensionEntry { + enabled: enable_imported_extensions, + config: ExtensionConfig::Stdio { + name, + description: "Imported from Claude Desktop".to_string(), + cmd: command, + args: server.args, + envs: Envs::new(server.env), + env_keys: Vec::new(), + timeout: Some(crate::config::DEFAULT_EXTENSION_TIMEOUT), + bundled: None, + available_tools: Vec::new(), + }, + }; + target_extensions.insert(key, serde_yaml::to_value(entry)?); + result.imported.extensions += 1; + } + + if result.imported.extensions > 0 { + target_config.set_param("extensions", target_extensions)?; + } + + result.warnings.extend(warnings); + if !enable_imported_extensions && result.imported.extensions > 0 { + result + .warnings + .push("Imported Claude Desktop MCP servers are disabled by default.".to_string()); + } + Ok(result) +} + +fn read_claude_servers( + path: &Path, +) -> anyhow::Result<(HashMap, Vec)> { + let content = fs::read_to_string(path)?; + let config: ClaudeDesktopConfig = serde_json::from_str(&content)?; + let mut servers = HashMap::new(); + let mut warnings = Vec::new(); + + for (name, server) in config.mcp_servers { + if server + .command + .as_deref() + .is_some_and(|command| !command.trim().is_empty()) + { + servers.insert(name, server); + } else { + warnings.push(format!("Claude MCP server '{name}' has no command.")); + } + } + + Ok((servers, warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn scan_claude_desktop_counts_valid_servers() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("claude_desktop_config.json"); + fs::write( + &path, + r#"{ + "mcpServers": { + "github": { "command": "npx", "args": ["github-mcp"] }, + "broken": { "args": ["missing-command"] } + } + }"#, + ) + .unwrap(); + + let candidate = scan_claude_desktop_candidate(&path).unwrap(); + assert_eq!(candidate.counts.extensions, 1); + assert_eq!(candidate.warnings.len(), 1); + } + + #[test] + fn apply_onboarding_imports_continues_after_candidate_failure() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + let missing_goose_config = source.path().join("missing-config.yaml"); + let claude_config = source.path().join("claude_desktop_config.json"); + fs::write( + &claude_config, + r#"{ + "mcpServers": { + "github": { "command": "npx", "args": ["github-mcp"] } + } + }"#, + ) + .unwrap(); + let target_config = Config::new_with_file_secrets( + target.path().join(CONFIG_YAML_NAME), + target.path().join("secrets.yaml"), + ) + .unwrap(); + let req = OnboardingImportApplyRequest { + candidate_ids: vec![ + candidate_id(GOOSE_CONFIG_PREFIX, &missing_goose_config), + candidate_id(CLAUDE_DESKTOP_PREFIX, &claude_config), + ], + enable_imported_extensions: false, + }; + + let response = apply_onboarding_import_candidates(&target_config, target.path(), &req); + + assert_eq!(response.imported.extensions, 1); + assert!(response + .warnings + .iter() + .any(|warning| warning.starts_with("Skipped Goose configuration import at "))); + let extensions = target_config.get_param::("extensions").unwrap(); + assert!(extensions.contains_key(serde_yaml::Value::String(name_to_key("github")))); + } + + #[test] + fn apply_goose_config_imports_defaults_extensions_and_skills() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + let source_config = source.path().join(CONFIG_YAML_NAME); + fs::write( + &source_config, + r#" +GOOSE_PROVIDER: openai +GOOSE_MODEL: gpt-5.1 +extensions: + github: + enabled: true + type: stdio + name: github + description: GitHub + cmd: npx + args: ["github-mcp"] +"#, + ) + .unwrap(); + let skill_dir = source.path().join("skills").join("reviewer"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "# Reviewer").unwrap(); + + let target_config = Config::new_with_file_secrets( + target.path().join(CONFIG_YAML_NAME), + target.path().join("secrets.yaml"), + ) + .unwrap(); + + let result = + apply_goose_config_candidate(&target_config, target.path(), &source_config).unwrap(); + + assert_eq!(result.imported.providers, 1); + assert_eq!(result.imported.extensions, 1); + assert_eq!(result.imported.skills, 1); + assert_eq!( + target_config.get_param::("GOOSE_PROVIDER").unwrap(), + "openai" + ); + assert!(target.path().join("skills").join("reviewer").exists()); + } + + #[cfg(unix)] + #[test] + fn import_skill_dirs_skips_symlink_cycles() { + let source = TempDir::new().unwrap(); + let target = TempDir::new().unwrap(); + let skill_dir = source.path().join("reviewer"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), "# Reviewer").unwrap(); + std::os::unix::fs::symlink(&skill_dir, skill_dir.join("loop")).unwrap(); + + let result = import_skill_dirs(source.path(), target.path()).unwrap(); + + assert_eq!(result.imported, 1); + assert!(target.path().join("reviewer").join("SKILL.md").exists()); + assert!(!target.path().join("reviewer").join("loop").exists()); + } +} diff --git a/crates/goose/src/acp/server/providers.rs b/crates/goose/src/acp/server/providers.rs index 52ac1cbec2..4fd4ad913a 100644 --- a/crates/goose/src/acp/server/providers.rs +++ b/crates/goose/src/acp/server/providers.rs @@ -894,4 +894,35 @@ impl GooseAcpAgent { let refresh = self.start_provider_inventory_refresh(&provider_ids).await?; Ok(ProviderConfigChangeResponse { status, refresh }) } + + pub(super) async fn on_authenticate_provider_config( + &self, + req: ProviderConfigAuthenticateRequest, + ) -> Result { + let entry = crate::providers::get_from_registry(&req.provider_id) + .await + .invalid_params_err_ctx("Unknown provider")?; + let metadata = entry.metadata().clone(); + if !metadata.config_keys.iter().any(|key| key.oauth_flow) { + return Err(sacp::Error::invalid_params().data(format!( + "Provider does not support native authentication: {}", + req.provider_id + ))); + } + + let provider = entry + .create_with_default_model(Vec::new()) + .await + .internal_err_ctx("Failed to initialize provider")?; + provider + .configure_oauth() + .await + .internal_err_ctx("Failed to authenticate provider")?; + Config::global().invalidate_secrets_cache(); + + let provider_ids = [req.provider_id.clone()]; + let status = Self::provider_config_status(req.provider_id.clone()).await; + let refresh = self.start_provider_inventory_refresh(&provider_ids).await?; + Ok(ProviderConfigChangeResponse { status, refresh }) + } } diff --git a/crates/goose/src/providers/chatgpt_codex.rs b/crates/goose/src/providers/chatgpt_codex.rs index 03f20788b2..4bdc8c5802 100644 --- a/crates/goose/src/providers/chatgpt_codex.rs +++ b/crates/goose/src/providers/chatgpt_codex.rs @@ -943,6 +943,10 @@ impl ProviderDef for ChatGptCodexProvider { ) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } + + fn inventory_configured() -> bool { + TokenCache::new().load().is_some() + } } #[async_trait] @@ -1050,6 +1054,29 @@ mod tests { .unwrap_or_default() } + #[test] + #[serial_test::serial] + fn inventory_configured_uses_oauth_token_cache() { + let root = tempfile::tempdir().unwrap(); + let root_path = root.path().to_string_lossy().to_string(); + let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(root_path.as_str()))]); + + TokenCache::new().clear(); + assert!(!ChatGptCodexProvider::inventory_configured()); + + TokenCache::new() + .save(&TokenData { + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + id_token: None, + expires_at: Utc::now() + chrono::Duration::hours(1), + account_id: Some("account".to_string()), + }) + .unwrap(); + + assert!(ChatGptCodexProvider::inventory_configured()); + } + #[test_case( vec![ Message::user().with_text("user text"), diff --git a/crates/goose/tests/acp_custom_provider_methods_test.rs b/crates/goose/tests/acp_custom_provider_methods_test.rs index ef8e56cc3e..70ac300832 100644 --- a/crates/goose/tests/acp_custom_provider_methods_test.rs +++ b/crates/goose/tests/acp_custom_provider_methods_test.rs @@ -192,6 +192,17 @@ fn acp_catalog_and_custom_provider_methods_use_core_provider_store() { "provider config read should not expose raw secret values" ); + let non_oauth_auth = send_custom( + conn.cx(), + "_goose/providers/config/authenticate", + serde_json::json!({ "providerId": "xai" }), + ) + .await; + assert!( + non_oauth_auth.is_err(), + "native auth should reject providers without an OAuth flow" + ); + Config::global().invalidate_secrets_cache(); assert!(Config::global() .get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY") diff --git a/ui/goose2/src/app/AppShell.tsx b/ui/goose2/src/app/AppShell.tsx index 45374f30f2..7ecfad684b 100644 --- a/ui/goose2/src/app/AppShell.tsx +++ b/ui/goose2/src/app/AppShell.tsx @@ -33,6 +33,9 @@ import { useProviderInventoryStore } from "@/features/providers/stores/providerI import { sanitizeReplayMessages } from "@/features/chat/lib/replaySanitizer"; import type { SkillInfo } from "@/features/skills/api/skills"; import { toChatSkillDraft } from "@/features/skills/lib/skillChatPrompt"; +import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; +import { useOnboardingGate } from "@/features/onboarding/hooks/useOnboardingGate"; +import { Spinner } from "@/shared/ui/spinner"; export type AppView = | "home" @@ -122,6 +125,8 @@ export function AppShell({ children }: { children?: React.ReactNode }) { const agentStore = useAgentStore(); const projectStore = useProjectStore(); const providerInventoryEntries = useProviderInventoryStore((s) => s.entries); + const startup = useAppStartup(); + const onboardingGate = useOnboardingGate(startup.ready); const pendingProjectCreatedRef = useRef<((projectId: string) => void) | null>( null, ); @@ -176,8 +181,6 @@ export function AppShell({ children }: { children?: React.ReactNode }) { } }, []); - useAppStartup(); - useEffect(() => { projectStore.fetchProjects(); }, [projectStore.fetchProjects]); @@ -291,13 +294,13 @@ export function AppShell({ children }: { children?: React.ReactNode }) { ]); useEffect(() => { - if (activeView !== "home") { + if (activeView !== "home" || onboardingGate.shouldShowOnboarding) { return; } void ensureHomeSession().catch((error) => { console.error("Failed to ensure Home session:", error); }); - }, [activeView, ensureHomeSession]); + }, [activeView, ensureHomeSession, onboardingGate.shouldShowOnboarding]); const createNewTab = useCallback( async (title = DEFAULT_CHAT_TITLE, project?: ProjectInfo) => { @@ -722,6 +725,26 @@ export function AppShell({ children }: { children?: React.ReactNode }) { return () => window.removeEventListener("keydown", handler); }, [clearActiveSession, sessionStore, toggleSidebar]); + if (!startup.ready) { + return ( +
+ +
+ ); + } + + if (onboardingGate.shouldShowOnboarding) { + return ( + { + onboardingGate.completeOnboarding(setup); + setActiveView("home"); + }} + /> + ); + } + return (
diff --git a/ui/goose2/src/app/hooks/useAppStartup.ts b/ui/goose2/src/app/hooks/useAppStartup.ts index d0b57137d1..ac15339cc1 100644 --- a/ui/goose2/src/app/hooks/useAppStartup.ts +++ b/ui/goose2/src/app/hooks/useAppStartup.ts @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useAgentStore } from "@/features/agents/stores/agentStore"; import { useChatSessionStore } from "@/features/chat/stores/chatSessionStore"; import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; @@ -38,7 +38,11 @@ export function filterStartupProvidersForDistro( } export function useAppStartup() { + const [ready, setReady] = useState(false); + const [error, setError] = useState(null); + useEffect(() => { + let cancelled = false; (async () => { const tStartup = performance.now(); perfLog("[perf:startup] useAppStartup begin"); @@ -51,6 +55,7 @@ export function useAppStartup() { ); } catch (err) { console.error("Failed to initialize ACP connection:", err); + setError(err); } const store = useAgentStore.getState(); @@ -196,6 +201,21 @@ export function useAppStartup() { perfLog( `[perf:startup] useAppStartup complete in ${(performance.now() - tStartup).toFixed(1)}ms`, ); - })(); + })() + .catch((err) => { + console.error("Failed to complete app startup:", err); + setError(err); + }) + .finally(() => { + if (!cancelled) { + setReady(true); + } + }); + + return () => { + cancelled = true; + }; }, []); + + return { ready, error }; } diff --git a/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx b/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx index fc0544d17b..9ff982adce 100644 --- a/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx +++ b/ui/goose2/src/features/agents/ui/AvatarDropZone.tsx @@ -190,9 +190,9 @@ export function AvatarDropZone({ onDrop={handleDrop} onClick={handleClick} className={cn( - "size-16 overflow-hidden border-2 bg-muted shadow-sm", + "size-16 overflow-hidden border-2 bg-muted shadow-none", isDragOver - ? "scale-105 border-brand bg-brand/10 shadow-md ring-4 ring-brand/20" + ? "scale-105 border-brand bg-brand/10 shadow-none ring-4 ring-brand/20" : "border-border hover:border-brand/50 hover:bg-brand/10", disabled && "opacity-70 cursor-not-allowed", isUploading && "animate-pulse", @@ -220,7 +220,7 @@ export function AvatarDropZone({ size="icon-xs" aria-label={t("avatar.removeAria")} onClick={handleClear} - className="absolute -top-0.5 -right-0.5 z-10 size-5 bg-background text-muted-foreground shadow-sm hover:text-foreground" + className="absolute -top-0.5 -right-0.5 z-10 size-5 bg-background text-muted-foreground shadow-none hover:text-foreground" > diff --git a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx index 946bac0b63..5d94d12b89 100644 --- a/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx +++ b/ui/goose2/src/features/chat/ui/ChatContextPanel.tsx @@ -122,7 +122,7 @@ export function ChatContextPanel({ className={ isOpen ? "text-muted-foreground transition-opacity duration-150 hover:text-foreground" - : "h-9 w-11 rounded-sm border border-border bg-background/80 text-muted-foreground shadow-sm backdrop-blur-sm transition-opacity duration-150 hover:bg-accent/50 hover:text-foreground" + : "h-9 w-11 rounded-sm border border-border bg-background/80 text-muted-foreground shadow-none backdrop-blur-sm transition-opacity duration-150 hover:bg-accent/50 hover:text-foreground" } aria-label={label} title={label} diff --git a/ui/goose2/src/features/onboarding/api/onboarding.ts b/ui/goose2/src/features/onboarding/api/onboarding.ts new file mode 100644 index 0000000000..d01964b798 --- /dev/null +++ b/ui/goose2/src/features/onboarding/api/onboarding.ts @@ -0,0 +1,37 @@ +import type { + DefaultsReadResponse, + DefaultsSaveRequest, + OnboardingImportApplyRequest, + OnboardingImportApplyResponse, + OnboardingImportCandidate, +} from "@aaif/goose-sdk"; +import { getClient } from "@/shared/api/acpConnection"; + +export async function readDefaults(): Promise { + const client = await getClient(); + return client.goose.GooseDefaultsRead({}); +} + +export async function saveDefaults( + params: DefaultsSaveRequest, +): Promise { + const client = await getClient(); + return client.goose.GooseDefaultsSave(params); +} + +export async function scanOnboardingImports(): Promise< + OnboardingImportCandidate[] +> { + const client = await getClient(); + const response = await client.goose.GooseOnboardingImportScan({ + sources: [], + }); + return response.candidates; +} + +export async function applyOnboardingImports( + params: OnboardingImportApplyRequest, +): Promise { + const client = await getClient(); + return client.goose.GooseOnboardingImportApply(params); +} diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingFlow.ts b/ui/goose2/src/features/onboarding/hooks/useOnboardingFlow.ts new file mode 100644 index 0000000000..d2e0348cd4 --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingFlow.ts @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { setStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { getAgentProviders } from "@/features/providers/providerCatalog"; +import { useOnboardingImportStep } from "./useOnboardingImportStep"; +import { useOnboardingProviderStep } from "./useOnboardingProviderStep"; +import { useOnboardingReadyStep } from "./useOnboardingReadyStep"; +import type { + OnboardingReadiness, + OnboardingStep, + SelectedSetup, + TFunctionLike, +} from "../types"; + +interface UseOnboardingFlowParams { + readiness: OnboardingReadiness; + onComplete: (setup: SelectedSetup) => void; + t: TFunctionLike; +} + +export function useOnboardingFlow({ + readiness, + onComplete, + t, +}: UseOnboardingFlowParams) { + const [step, setStep] = useState("import"); + const [selectedSetup, setSelectedSetup] = useState( + () => + readiness.isUsable && readiness.providerId + ? { + providerId: readiness.providerId, + modelId: readiness.modelId, + modelName: readiness.modelName, + } + : null, + ); + + const importStep = useOnboardingImportStep({ + t, + onProviderDefaults: setSelectedSetup, + onContinue: () => setStep("provider"), + }); + + const providerStep = useOnboardingProviderStep({ + readiness, + t, + onSelectedSetup: setSelectedSetup, + onReady: () => setStep("tour"), + }); + + const readyStep = useOnboardingReadyStep(step); + + function finish() { + const setup = + selectedSetup ?? + (readiness.providerId + ? { + providerId: readiness.providerId, + modelId: readiness.modelId, + modelName: readiness.modelName, + } + : null); + if (!setup) { + setStep("provider"); + return; + } + if ( + setup.modelId && + !getAgentProviders().some((provider) => provider.id === setup.providerId) + ) { + setStoredModelPreference("goose", { + providerId: setup.providerId, + modelId: setup.modelId, + modelName: setup.modelName ?? setup.modelId, + }); + } + onComplete(setup); + } + + return { + step, + stepOrder: ["import", "provider", "tour"] as const, + importStep: importStep.props, + providerStep, + readyStep: { + availableSkillCount: readyStep.availableSkillCount, + setupCounts: importStep.setupCounts, + selectedSetup, + onBack: () => setStep("provider"), + onFinish: finish, + }, + }; +} diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.test.tsx b/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.test.tsx new file mode 100644 index 0000000000..b8df7ab658 --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.test.tsx @@ -0,0 +1,205 @@ +import { act, renderHook } from "@testing-library/react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { setStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { ONBOARDING_STORAGE_KEY } from "../types"; +import { useOnboardingGate } from "./useOnboardingGate"; + +function providerEntry( + overrides: Partial, +): ProviderInventoryEntryDto { + const providerId = overrides.providerId ?? "anthropic"; + + return { + providerId, + providerName: overrides.providerName ?? providerId, + description: "", + defaultModel: "claude-sonnet-4-5", + configured: true, + providerType: "Preferred", + category: "model", + configKeys: [], + setupSteps: [], + supportsRefresh: true, + refreshing: false, + models: [ + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + family: "claude", + contextLimit: 200000, + recommended: true, + }, + ], + stale: false, + ...overrides, + }; +} + +function writeCompletedOnboarding(providerId = "anthropic", modelId?: string) { + window.localStorage.setItem( + ONBOARDING_STORAGE_KEY, + JSON.stringify({ + completedAt: "2026-05-05T12:00:00.000Z", + providerId, + modelId, + }), + ); +} + +describe("useOnboardingGate", () => { + beforeEach(() => { + window.localStorage.clear(); + useAgentStore.setState({ + selectedProvider: "goose", + providers: [], + }); + useProviderInventoryStore.setState({ + entries: new Map(), + loading: false, + }); + useProviderCatalogStore.getState().setEntries([ + { + id: "anthropic", + displayName: "Anthropic", + category: "model", + description: "", + setupMethod: "single_api_key", + group: "default", + }, + { + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "", + setupMethod: "cli_auth", + group: "additional", + }, + ]); + }); + + it("shows onboarding after startup when there is no completed state", () => { + useProviderInventoryStore.getState().setEntries([providerEntry({})]); + + const { result } = renderHook(() => useOnboardingGate(true)); + + expect(result.current.shouldShowOnboarding).toBe(true); + expect(result.current.readiness.reason).toBe("ready"); + }); + + it("skips onboarding when completion and the selected Goose model are usable", () => { + writeCompletedOnboarding("anthropic", "claude-sonnet-4-5"); + setStoredModelPreference("goose", { + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }); + useProviderInventoryStore.getState().setEntries([providerEntry({})]); + + const { result } = renderHook(() => useOnboardingGate(true)); + + expect(result.current.shouldShowOnboarding).toBe(false); + expect(result.current.readiness.isUsable).toBe(true); + expect(result.current.readiness.providerId).toBe("anthropic"); + }); + + it("reopens onboarding when the completed Goose provider is no longer usable", () => { + writeCompletedOnboarding("anthropic", "claude-sonnet-4-5"); + setStoredModelPreference("goose", { + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }); + useProviderInventoryStore.getState().setEntries([ + providerEntry({ + configured: false, + models: [], + }), + ]); + + const { result } = renderHook(() => useOnboardingGate(true)); + + expect(result.current.shouldShowOnboarding).toBe(true); + expect(result.current.readiness.isUsable).toBe(false); + expect(result.current.readiness.reason).toBe("missing_provider"); + }); + + it("treats a completed ACP agent provider with models as usable", () => { + writeCompletedOnboarding("claude-acp", "claude-acp-session"); + useAgentStore.setState({ selectedProvider: "claude-acp" }); + useProviderInventoryStore.getState().setEntries([ + providerEntry({ + providerId: "claude-acp", + providerName: "Claude Code", + providerType: "Acp", + category: "agent", + defaultModel: "claude-acp-session", + models: [ + { + id: "claude-acp-session", + name: "Claude Code", + family: "acp", + contextLimit: null, + recommended: true, + }, + ], + }), + ]); + + const { result } = renderHook(() => useOnboardingGate(true)); + + expect(result.current.shouldShowOnboarding).toBe(false); + expect(result.current.readiness.providerId).toBe("claude-acp"); + expect(result.current.readiness.reason).toBe("ready"); + }); + + it("falls back to a usable Goose model when the selected ACP agent is unusable", () => { + writeCompletedOnboarding("claude-acp", "claude-acp-session"); + setStoredModelPreference("goose", { + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }); + useAgentStore.setState({ selectedProvider: "claude-acp" }); + useProviderInventoryStore.getState().setEntries([ + providerEntry({}), + providerEntry({ + providerId: "claude-acp", + providerName: "Claude Code", + providerType: "Acp", + category: "agent", + defaultModel: "claude-acp-session", + configured: false, + models: [], + }), + ]); + + const { result } = renderHook(() => useOnboardingGate(true)); + + expect(result.current.shouldShowOnboarding).toBe(false); + expect(result.current.readiness.isUsable).toBe(true); + expect(result.current.readiness.providerId).toBe("anthropic"); + expect(result.current.readiness.reason).toBe("ready"); + }); + + it("persists completion from the onboarding flow", () => { + const { result } = renderHook(() => useOnboardingGate(true)); + + act(() => { + result.current.completeOnboarding({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + }); + }); + + expect( + JSON.parse(window.localStorage.getItem(ONBOARDING_STORAGE_KEY) ?? "{}"), + ).toMatchObject({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + }); + }); +}); diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.ts b/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.ts new file mode 100644 index 0000000000..21bdffa4d1 --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingGate.ts @@ -0,0 +1,168 @@ +import { useCallback, useMemo, useState } from "react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { + getModelProviders, + resolveAgentProviderCatalogIdStrict, +} from "@/features/providers/providerCatalog"; +import { useProviderInventory } from "@/features/providers/hooks/useProviderInventory"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; +import { filterModelProvidersForDistro } from "@/features/providers/distroProviderConstraints"; +import { getStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { + ONBOARDING_STORAGE_KEY, + type OnboardingCompletion, + type OnboardingReadiness, +} from "../types"; + +function readCompletion(): OnboardingCompletion | null { + try { + const raw = localStorage.getItem(ONBOARDING_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Partial; + if (!parsed.completedAt || !parsed.providerId) return null; + return { + completedAt: parsed.completedAt, + providerId: parsed.providerId, + modelId: parsed.modelId, + }; + } catch { + return null; + } +} + +function writeCompletion(completion: OnboardingCompletion) { + localStorage.setItem(ONBOARDING_STORAGE_KEY, JSON.stringify(completion)); +} + +export function resetOnboardingCompletion() { + localStorage.removeItem(ONBOARDING_STORAGE_KEY); +} + +function firstUsableModel(entry: ProviderInventoryEntryDto) { + return ( + entry.models.find((model) => model.recommended) ?? + entry.models.find((model) => model.id === entry.defaultModel) ?? + entry.models[0] + ); +} + +export function useOnboardingGate(startupReady: boolean) { + const selectedProvider = useAgentStore((state) => state.selectedProvider); + const { entries, configuredModelProviderEntries, getModelsForAgent } = + useProviderInventory(); + const distro = useDistroStore((state) => state.manifest); + const [completion, setCompletion] = useState( + readCompletion, + ); + + const modelProviderIds = useMemo( + () => + new Set( + filterModelProvidersForDistro(getModelProviders(), distro).map( + (provider) => provider.id, + ), + ), + [distro], + ); + + const readiness = useMemo(() => { + const selectedAgentId = + resolveAgentProviderCatalogIdStrict(selectedProvider) ?? "goose"; + + if (selectedAgentId !== "goose") { + const models = getModelsForAgent(selectedAgentId); + const entry = entries.get(selectedAgentId); + const isReady = !!entry?.configured && models.length > 0; + if (isReady) { + return { + hasCompletedOnboarding: !!completion, + isUsable: true, + providerId: selectedAgentId, + modelId: models[0]?.id, + modelName: models[0]?.name, + reason: "ready", + }; + } + } + + const storedGooseModel = getStoredModelPreference("goose"); + if (storedGooseModel) { + const entry = entries.get(storedGooseModel.providerId ?? ""); + const modelStillExists = entry?.models.some( + (model) => model.id === storedGooseModel.modelId, + ); + if (entry?.configured && modelStillExists) { + return { + hasCompletedOnboarding: !!completion, + isUsable: true, + providerId: storedGooseModel.providerId ?? "goose", + modelId: storedGooseModel.modelId, + modelName: storedGooseModel.modelName, + reason: "ready", + }; + } + } + + const configuredEntry = + configuredModelProviderEntries.find( + (entry) => + (entry.providerType === "Custom" || + modelProviderIds.has(entry.providerId)) && + firstUsableModel(entry), + ) ?? null; + const model = configuredEntry ? firstUsableModel(configuredEntry) : null; + + if (configuredEntry && model) { + return { + hasCompletedOnboarding: !!completion, + isUsable: true, + providerId: configuredEntry.providerId, + modelId: model.id, + modelName: model.name, + reason: "ready", + }; + } + + return { + hasCompletedOnboarding: !!completion, + isUsable: false, + providerId: null, + reason: completion ? "missing_provider" : "not_completed", + }; + }, [ + completion, + configuredModelProviderEntries, + entries, + getModelsForAgent, + modelProviderIds, + selectedProvider, + ]); + + const completeOnboarding = useCallback( + (next: Omit) => { + const completionValue = { + ...next, + completedAt: new Date().toISOString(), + }; + writeCompletion(completionValue); + setCompletion(completionValue); + }, + [], + ); + + const resetOnboarding = useCallback(() => { + resetOnboardingCompletion(); + setCompletion(null); + }, []); + + return { + completion, + readiness, + shouldShowOnboarding: + startupReady && + (!readiness.hasCompletedOnboarding || !readiness.isUsable), + completeOnboarding, + resetOnboarding, + }; +} diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingImportStep.ts b/ui/goose2/src/features/onboarding/hooks/useOnboardingImportStep.ts new file mode 100644 index 0000000000..757d65a1a1 --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingImportStep.ts @@ -0,0 +1,152 @@ +import { useEffect, useState } from "react"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { setStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { getProviderInventory } from "@/features/providers/api/inventory"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { + applyOnboardingImports, + scanOnboardingImports, +} from "../api/onboarding"; +import { + emptyImportCounts, + hasImportableCounts, + sumImportCounts, +} from "../lib/importCounts"; +import type { + OnboardingImportCandidate, + OnboardingImportCounts, + SelectedSetup, + TFunctionLike, +} from "../types"; + +interface UseOnboardingImportStepParams { + t: TFunctionLike; + onProviderDefaults: (setup: SelectedSetup) => void; + onContinue: () => void; +} + +export function useOnboardingImportStep({ + t, + onProviderDefaults, + onContinue, +}: UseOnboardingImportStepParams) { + const [candidates, setCandidates] = useState([]); + const [selectedCandidateIds, setSelectedCandidateIds] = useState>( + () => new Set(), + ); + const [scanLoading, setScanLoading] = useState(true); + const [importing, setImporting] = useState(false); + const [importError, setImportError] = useState(""); + const [setupCounts, setSetupCounts] = + useState(emptyImportCounts); + + const setInventoryEntries = useProviderInventoryStore( + (state) => state.setEntries, + ); + const agentStore = useAgentStore(); + + useEffect(() => { + let cancelled = false; + setScanLoading(true); + scanOnboardingImports() + .then((nextCandidates) => { + if (cancelled) return; + const importable = nextCandidates.filter(hasImportableCounts); + setCandidates(importable); + setSelectedCandidateIds(new Set(importable.map((item) => item.id))); + }) + .catch((error) => { + if (!cancelled) { + setImportError( + error instanceof Error + ? error.message + : t("onboarding:import.scanFailed"), + ); + } + }) + .finally(() => { + if (!cancelled) { + setScanLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [t]); + + async function refreshInventory() { + const entries = await getProviderInventory(); + setInventoryEntries(entries); + return entries; + } + + function toggleCandidate(id: string) { + setSelectedCandidateIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + } + + function skipImport() { + setSetupCounts(emptyImportCounts()); + onContinue(); + } + + async function applyImports() { + if (selectedCandidateIds.size === 0) { + skipImport(); + return; + } + + setImporting(true); + setImportError(""); + try { + const result = await applyOnboardingImports({ + candidateIds: [...selectedCandidateIds], + enableImportedExtensions: false, + }); + setSetupCounts(sumImportCounts(result.imported, result.skipped)); + await refreshInventory(); + if ( + result.providerDefaults?.providerId && + result.providerDefaults.modelId + ) { + const setup = { + providerId: result.providerDefaults.providerId, + modelId: result.providerDefaults.modelId, + modelName: result.providerDefaults.modelId, + }; + setStoredModelPreference("goose", setup); + agentStore.setSelectedProvider("goose"); + onProviderDefaults(setup); + } + onContinue(); + } catch (error) { + setImportError( + error instanceof Error ? error.message : t("onboarding:import.failed"), + ); + } finally { + setImporting(false); + } + } + + return { + setupCounts, + props: { + candidates, + selectedCandidateIds, + scanLoading, + importing, + importError, + onToggleCandidate: toggleCandidate, + onSkip: skipImport, + onContinue: () => void applyImports(), + }, + }; +} diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.test.tsx b/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.test.tsx new file mode 100644 index 0000000000..ae38b558ff --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.test.tsx @@ -0,0 +1,184 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { getStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import type { OnboardingReadiness } from "../types"; +import { useOnboardingProviderStep } from "./useOnboardingProviderStep"; + +const mocks = vi.hoisted(() => ({ + saveDefaults: vi.fn(), +})); + +vi.mock("../api/onboarding", () => ({ + saveDefaults: mocks.saveDefaults, +})); + +vi.mock("@/features/providers/hooks/useCredentials", () => ({ + useCredentials: () => ({ + configuredIds: new Set(), + loading: false, + savingProviderIds: new Set(), + syncingProviderIds: new Set(), + inventoryWarnings: new Map(), + getConfig: vi.fn(), + save: vi.fn(), + remove: vi.fn(), + completeNativeSetup: vi.fn(), + }), +})); + +function providerEntry( + overrides: Partial, +): ProviderInventoryEntryDto { + const providerId = overrides.providerId ?? "anthropic"; + + return { + providerId, + providerName: overrides.providerName ?? providerId, + description: "", + defaultModel: overrides.defaultModel ?? "claude-sonnet-4-5", + configured: true, + providerType: "Preferred", + category: "model", + configKeys: [], + setupSteps: [], + supportsRefresh: true, + refreshing: false, + models: [ + { + id: "claude-sonnet-4-5", + name: "Claude Sonnet 4.5", + family: "claude", + contextLimit: 200000, + recommended: true, + }, + ], + stale: false, + ...overrides, + }; +} + +function readyReadiness( + overrides: Partial = {}, +): OnboardingReadiness { + return { + hasCompletedOnboarding: true, + isUsable: true, + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + reason: "ready", + ...overrides, + }; +} + +function renderProviderStep(readiness: OnboardingReadiness) { + const onSelectedSetup = vi.fn(); + const onReady = vi.fn(); + + const result = renderHook(() => + useOnboardingProviderStep({ + readiness, + t: (key) => key, + onSelectedSetup, + onReady, + }), + ); + + return { + ...result, + onReady, + onSelectedSetup, + }; +} + +describe("useOnboardingProviderStep", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + mocks.saveDefaults.mockResolvedValue({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + }); + useAgentStore.setState({ + selectedProvider: "goose", + providers: [], + }); + useProviderInventoryStore.setState({ + entries: new Map(), + loading: false, + }); + useProviderCatalogStore.getState().setEntries([ + { + id: "anthropic", + displayName: "Anthropic", + category: "model", + description: "", + setupMethod: "single_api_key", + group: "default", + }, + { + id: "claude-acp", + displayName: "Claude Code", + category: "agent", + description: "", + setupMethod: "cli_auth", + group: "default", + }, + ]); + useProviderInventoryStore.getState().setEntries([providerEntry({})]); + }); + + it("saves Goose defaults before continuing with the current model setup", async () => { + const { result, onReady, onSelectedSetup } = renderProviderStep( + readyReadiness(), + ); + + act(() => { + result.current.onContinue(); + }); + + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)); + expect(mocks.saveDefaults).toHaveBeenCalledWith({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + }); + expect(onSelectedSetup).toHaveBeenCalledWith({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + modelName: "Claude Sonnet 4.5", + }); + expect(useAgentStore.getState().selectedProvider).toBe("goose"); + expect(getStoredModelPreference("goose")).toMatchObject({ + providerId: "anthropic", + modelId: "claude-sonnet-4-5", + }); + }); + + it("continues with a current ACP agent without writing Goose model defaults", async () => { + const { result, onReady, onSelectedSetup } = renderProviderStep( + readyReadiness({ + providerId: "claude-acp", + modelId: "default", + modelName: "Claude Code", + }), + ); + + act(() => { + result.current.onContinue(); + }); + + await waitFor(() => expect(onReady).toHaveBeenCalledTimes(1)); + expect(mocks.saveDefaults).not.toHaveBeenCalled(); + expect(onSelectedSetup).toHaveBeenCalledWith({ + providerId: "claude-acp", + modelId: "default", + modelName: "Claude Code", + }); + expect(useAgentStore.getState().selectedProvider).toBe("claude-acp"); + expect(getStoredModelPreference("goose")).toBeNull(); + }); +}); diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.ts b/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.ts new file mode 100644 index 0000000000..9623a8aaf4 --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingProviderStep.ts @@ -0,0 +1,230 @@ +import { useMemo, useState } from "react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { useAgentStore } from "@/features/agents/stores/agentStore"; +import { setStoredModelPreference } from "@/features/chat/lib/modelPreferences"; +import { + getAgentProviders, + getModelProviders, +} from "@/features/providers/providerCatalog"; +import { filterModelProvidersForDistro } from "@/features/providers/distroProviderConstraints"; +import { useCredentials } from "@/features/providers/hooks/useCredentials"; +import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore"; +import { useDistroStore } from "@/features/settings/stores/distroStore"; +import { saveDefaults } from "../api/onboarding"; +import { + firstUsableModel, + PROMOTED_MODEL_ORDER, +} from "../lib/providerDefaults"; +import type { + OnboardingReadiness, + SelectedSetup, + TFunctionLike, + UsableDefaultEntry, +} from "../types"; + +interface UseOnboardingProviderStepParams { + readiness: OnboardingReadiness; + t: TFunctionLike; + onSelectedSetup: (setup: SelectedSetup) => void; + onReady: () => void; +} + +export function useOnboardingProviderStep({ + readiness, + t, + onSelectedSetup, + onReady, +}: UseOnboardingProviderStepParams) { + const [providerError, setProviderError] = useState(""); + const [showAllProviders, setShowAllProviders] = useState(false); + const [selectingProviderId, setSelectingProviderId] = useState( + null, + ); + + const inventoryEntries = useProviderInventoryStore((state) => state.entries); + const agentStore = useAgentStore(); + const distro = useDistroStore((state) => state.manifest); + + const { + configuredIds, + loading: credentialLoading, + savingProviderIds, + syncingProviderIds, + inventoryWarnings, + getConfig, + save, + remove, + completeNativeSetup, + } = useCredentials(); + + const modelProviders = useMemo(() => { + const all = filterModelProvidersForDistro(getModelProviders(), distro); + return [...all].sort((a, b) => { + const aIndex = PROMOTED_MODEL_ORDER.indexOf(a.id); + const bIndex = PROMOTED_MODEL_ORDER.indexOf(b.id); + if (aIndex !== -1 || bIndex !== -1) { + return (aIndex === -1 ? 99 : aIndex) - (bIndex === -1 ? 99 : bIndex); + } + return a.displayName.localeCompare(b.displayName); + }); + }, [distro]); + + const visibleModelProviders = modelProviders.filter( + (provider) => + showAllProviders || + provider.group !== "additional" || + configuredIds.has(provider.id) || + inventoryEntries.get(provider.id)?.configured, + ); + + const usableModelEntries = useMemo( + () => + [...inventoryEntries.values()] + .filter((entry) => entry.configured && firstUsableModel(entry)) + .filter((entry) => + modelProviders.some((provider) => provider.id === entry.providerId), + ), + [inventoryEntries, modelProviders], + ); + + const usableAgentEntries = useMemo( + () => + [...inventoryEntries.values()].filter( + (entry) => + entry.configured && + entry.providerId !== "goose" && + getAgentProviders().some( + (provider) => provider.id === entry.providerId, + ) && + entry.models.length > 0, + ), + [inventoryEntries], + ); + + const usableDefaultEntries = useMemo( + () => [ + ...usableModelEntries.map((entry) => ({ + kind: "model" as const, + entry, + })), + ...usableAgentEntries.map((entry) => ({ + kind: "agent" as const, + entry, + })), + ], + [usableAgentEntries, usableModelEntries], + ); + + async function selectModelProvider(entry: ProviderInventoryEntryDto) { + const model = firstUsableModel(entry); + if (!model) { + setProviderError(t("onboarding:provider.noModels")); + return; + } + + setProviderError(""); + setSelectingProviderId(entry.providerId); + try { + await saveDefaults({ providerId: entry.providerId, modelId: model.id }); + const setup = { + providerId: entry.providerId, + modelId: model.id, + modelName: model.name, + }; + setStoredModelPreference("goose", setup); + agentStore.setSelectedProvider("goose"); + onSelectedSetup(setup); + onReady(); + } catch (error) { + setProviderError( + error instanceof Error + ? error.message + : t("onboarding:provider.selectFailed"), + ); + } finally { + setSelectingProviderId(null); + } + } + + function selectAgentProvider(entry: ProviderInventoryEntryDto) { + const model = firstUsableModel(entry); + agentStore.setSelectedProvider(entry.providerId); + onSelectedSetup({ + providerId: entry.providerId, + modelId: model?.id, + modelName: model?.name, + }); + onReady(); + } + + async function continueWithCurrentDefault() { + if (!readiness.isUsable || !readiness.providerId) { + return; + } + + const setup = { + providerId: readiness.providerId, + modelId: readiness.modelId, + modelName: readiness.modelName, + }; + const isAgentProvider = getAgentProviders().some( + (provider) => provider.id === readiness.providerId, + ); + + setProviderError(""); + setSelectingProviderId(readiness.providerId); + try { + if (isAgentProvider) { + agentStore.setSelectedProvider(readiness.providerId); + } else { + if (!readiness.modelId || !readiness.modelName) { + setProviderError(t("onboarding:provider.noModels")); + return; + } + await saveDefaults({ + providerId: readiness.providerId, + modelId: readiness.modelId, + }); + setStoredModelPreference("goose", { + providerId: readiness.providerId, + modelId: readiness.modelId, + modelName: readiness.modelName, + }); + agentStore.setSelectedProvider("goose"); + } + onSelectedSetup(setup); + onReady(); + } catch (error) { + setProviderError( + error instanceof Error + ? error.message + : t("onboarding:provider.selectFailed"), + ); + } finally { + setSelectingProviderId(null); + } + } + + return { + credentialLoading, + modelProviders: visibleModelProviders, + canBrowseAllProviders: + !showAllProviders && visibleModelProviders.length < modelProviders.length, + usableDefaultEntries, + configuredIds, + savingProviderIds, + syncingProviderIds, + inventoryWarnings, + selectingProviderId, + providerError, + onGetConfig: getConfig, + onSave: save, + onRemove: remove, + onCompleteNativeSetup: completeNativeSetup, + onSelectModelProvider: (entry: ProviderInventoryEntryDto) => + void selectModelProvider(entry), + onSelectAgentProvider: selectAgentProvider, + onBrowseAllProviders: () => setShowAllProviders(true), + onContinue: () => void continueWithCurrentDefault(), + }; +} diff --git a/ui/goose2/src/features/onboarding/hooks/useOnboardingReadyStep.ts b/ui/goose2/src/features/onboarding/hooks/useOnboardingReadyStep.ts new file mode 100644 index 0000000000..996a5f814a --- /dev/null +++ b/ui/goose2/src/features/onboarding/hooks/useOnboardingReadyStep.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react"; +import { listSkills } from "@/features/skills/api/skills"; +import type { OnboardingStep } from "../types"; + +export function useOnboardingReadyStep(step: OnboardingStep) { + const [availableSkillCount, setAvailableSkillCount] = useState( + null, + ); + + useEffect(() => { + if (step !== "tour") { + return; + } + + let cancelled = false; + listSkills() + .then((skills) => { + if (!cancelled) { + setAvailableSkillCount(skills.length); + } + }) + .catch(() => { + if (!cancelled) { + setAvailableSkillCount(null); + } + }); + + return () => { + cancelled = true; + }; + }, [step]); + + return { availableSkillCount }; +} diff --git a/ui/goose2/src/features/onboarding/lib/importCounts.ts b/ui/goose2/src/features/onboarding/lib/importCounts.ts new file mode 100644 index 0000000000..0c426a35a9 --- /dev/null +++ b/ui/goose2/src/features/onboarding/lib/importCounts.ts @@ -0,0 +1,58 @@ +import type { + OnboardingImportCandidate, + OnboardingImportCounts, + TFunctionLike, +} from "../types"; + +export const COUNT_KEYS = [ + "providers", + "extensions", + "sessions", + "skills", + "projects", + "preferences", +] as const; + +export type CountKey = (typeof COUNT_KEYS)[number]; + +export function emptyImportCounts(): OnboardingImportCounts { + return { + providers: 0, + extensions: 0, + sessions: 0, + skills: 0, + projects: 0, + preferences: 0, + }; +} + +export function hasImportableCounts(candidate: OnboardingImportCandidate) { + return COUNT_KEYS.some((key) => candidate.counts[key] > 0); +} + +export function sumImportCounts( + imported: OnboardingImportCounts, + skipped: OnboardingImportCounts, +) { + const counts = emptyImportCounts(); + for (const key of COUNT_KEYS) { + counts[key] = imported[key] + skipped[key]; + } + return counts; +} + +export function formatCount(key: CountKey, count: number, t: TFunctionLike) { + return t(count === 1 ? `counts.${key}` : `counts.${key}_plural`, { + count, + }); +} + +export function formatCandidateCounts( + candidate: OnboardingImportCandidate, + t: TFunctionLike, +) { + const parts = COUNT_KEYS.filter((key) => candidate.counts[key] > 0).map( + (key) => formatCount(key, candidate.counts[key], t), + ); + return parts.length > 0 ? parts.join(", ") : t("counts.none"); +} diff --git a/ui/goose2/src/features/onboarding/lib/providerDefaults.ts b/ui/goose2/src/features/onboarding/lib/providerDefaults.ts new file mode 100644 index 0000000000..5ba634837f --- /dev/null +++ b/ui/goose2/src/features/onboarding/lib/providerDefaults.ts @@ -0,0 +1,18 @@ +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; + +export const PROMOTED_MODEL_ORDER = [ + "chatgpt_codex", + "anthropic", + "openai", + "google", + "ollama", + "openrouter", +]; + +export function firstUsableModel(entry: ProviderInventoryEntryDto) { + return ( + entry.models.find((model) => model.recommended) ?? + entry.models.find((model) => model.id === entry.defaultModel) ?? + entry.models[0] + ); +} diff --git a/ui/goose2/src/features/onboarding/types.ts b/ui/goose2/src/features/onboarding/types.ts new file mode 100644 index 0000000000..1194c01a4b --- /dev/null +++ b/ui/goose2/src/features/onboarding/types.ts @@ -0,0 +1,55 @@ +import type { + DefaultsReadResponse, + ProviderInventoryEntryDto, + OnboardingImportApplyResponse, + OnboardingImportCandidate, + OnboardingImportCounts, +} from "@aaif/goose-sdk"; + +export const ONBOARDING_STORAGE_KEY = "goose:onboarding:v1"; + +export type { + DefaultsReadResponse, + OnboardingImportApplyResponse, + OnboardingImportCandidate, + OnboardingImportCounts, +}; + +export interface OnboardingCompletion { + completedAt: string; + providerId: string; + modelId?: string; +} + +export interface OnboardingReadiness { + hasCompletedOnboarding: boolean; + isUsable: boolean; + providerId: string | null; + modelId?: string; + modelName?: string; + reason: "ready" | "not_completed" | "missing_provider" | "missing_model"; +} + +export type OnboardingStep = "import" | "provider" | "tour"; + +export interface SelectedSetup { + providerId: string; + modelId?: string; + modelName?: string; +} + +export interface UsableDefaultEntry { + kind: "model" | "agent"; + entry: ProviderInventoryEntryDto; +} + +export interface ProviderFieldSaveInput { + key: string; + value: string; + isSecret: boolean; +} + +export type TFunctionLike = ( + key: string, + options?: Record, +) => string; diff --git a/ui/goose2/src/features/onboarding/ui/ImportStep.tsx b/ui/goose2/src/features/onboarding/ui/ImportStep.tsx new file mode 100644 index 0000000000..0095462cdf --- /dev/null +++ b/ui/goose2/src/features/onboarding/ui/ImportStep.tsx @@ -0,0 +1,145 @@ +import { + IconArrowRight, + IconCheck, + IconDatabaseImport, +} from "@tabler/icons-react"; +import { formatCandidateCounts } from "../lib/importCounts"; +import type { OnboardingImportCandidate, TFunctionLike } from "../types"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Spinner } from "@/shared/ui/spinner"; + +interface ImportStepProps { + candidates: OnboardingImportCandidate[]; + selectedCandidateIds: Set; + scanLoading: boolean; + importing: boolean; + importError: string; + onToggleCandidate: (id: string) => void; + onSkip: () => void; + onContinue: () => void; + t: TFunctionLike; +} + +export function ImportStep({ + candidates, + selectedCandidateIds, + scanLoading, + importing, + importError, + onToggleCandidate, + onSkip, + onContinue, + t, +}: ImportStepProps) { + const hasCandidates = candidates.length > 0; + + return ( +
+
+ +
+

+ {t("import.title")} +

+

+ {t("import.description")} +

+ +
+ {scanLoading ? ( +
+ + {t("import.scanning")} +
+ ) : !hasCandidates ? ( +
+

{t("import.emptyTitle")}

+

+ {t("import.emptyDescription")} +

+
+ ) : ( + candidates.map((candidate) => { + const warnings = candidate.warnings ?? []; + const checked = selectedCandidateIds.has(candidate.id); + return ( + + ); + }) + )} +
+ + {importError ? ( +

+ {importError} +

+ ) : null} + +
+ + +
+
+ ); +} diff --git a/ui/goose2/src/features/onboarding/ui/OnboardingFlow.tsx b/ui/goose2/src/features/onboarding/ui/OnboardingFlow.tsx new file mode 100644 index 0000000000..f3577b600f --- /dev/null +++ b/ui/goose2/src/features/onboarding/ui/OnboardingFlow.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { cn } from "@/shared/lib/cn"; +import { useOnboardingFlow } from "../hooks/useOnboardingFlow"; +import type { OnboardingReadiness, SelectedSetup } from "../types"; +import { ImportStep } from "./ImportStep"; +import { ProviderStep } from "./ProviderStep"; +import { ReadyStep } from "./ReadyStep"; + +interface OnboardingFlowProps { + readiness: OnboardingReadiness; + onComplete: (setup: SelectedSetup) => void; +} + +export function OnboardingFlow({ readiness, onComplete }: OnboardingFlowProps) { + const { t } = useTranslation(["onboarding", "common", "settings"]); + const flow = useOnboardingFlow({ readiness, onComplete, t }); + + return ( +
+
+
+ {t("onboarding:windowTitle")} +
+
+ +
+
+
+ {flow.step === "import" ? ( + + ) : null} + + {flow.step === "provider" ? ( + + ) : null} + + {flow.step === "tour" ? ( + + ) : null} +
+
+
+ +
+ {flow.stepOrder.map((item) => ( +
+
+ ); +} diff --git a/ui/goose2/src/features/onboarding/ui/ProviderStep.tsx b/ui/goose2/src/features/onboarding/ui/ProviderStep.tsx new file mode 100644 index 0000000000..6d80711bd9 --- /dev/null +++ b/ui/goose2/src/features/onboarding/ui/ProviderStep.tsx @@ -0,0 +1,221 @@ +import { useState } from "react"; +import type { ComponentProps } from "react"; +import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk"; +import { + IconArrowRight, + IconCheck, + IconPlugConnected, +} from "@tabler/icons-react"; +import type { getModelProviders } from "@/features/providers/providerCatalog"; +import { ModelProviderRow } from "@/features/settings/ui/ModelProviderRow"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { getProviderIcon } from "@/shared/ui/icons/ProviderIcons"; +import { Spinner } from "@/shared/ui/spinner"; +import { firstUsableModel } from "../lib/providerDefaults"; +import type { + ProviderFieldSaveInput, + TFunctionLike, + UsableDefaultEntry, +} from "../types"; + +interface ProviderStepProps { + credentialLoading: boolean; + modelProviders: ReturnType; + canBrowseAllProviders: boolean; + usableDefaultEntries: UsableDefaultEntry[]; + configuredIds: Set; + savingProviderIds: Set; + syncingProviderIds: Set; + inventoryWarnings: Map; + selectingProviderId: string | null; + providerError: string; + onGetConfig: ComponentProps["onGetConfig"]; + onSave: ( + providerId: string, + fields: ProviderFieldSaveInput[], + ) => Promise; + onRemove: (providerId: string) => Promise; + onCompleteNativeSetup: ComponentProps< + typeof ModelProviderRow + >["onCompleteNativeSetup"]; + onSelectModelProvider: (entry: ProviderInventoryEntryDto) => void; + onSelectAgentProvider: (entry: ProviderInventoryEntryDto) => void; + onBrowseAllProviders: () => void; + onContinue: () => void; + t: TFunctionLike; +} + +export function ProviderStep({ + credentialLoading, + modelProviders, + canBrowseAllProviders, + usableDefaultEntries, + configuredIds, + savingProviderIds, + syncingProviderIds, + inventoryWarnings, + selectingProviderId, + providerError, + onGetConfig, + onSave, + onRemove, + onCompleteNativeSetup, + onSelectModelProvider, + onSelectAgentProvider, + onBrowseAllProviders, + onContinue, + t, +}: ProviderStepProps) { + const hasUsableDefaults = usableDefaultEntries.length > 0; + const [showProviderSetup, setShowProviderSetup] = useState( + !hasUsableDefaults, + ); + + return ( +
+
+ {hasUsableDefaults ? ( + + ) : ( + + )} +
+

+ {hasUsableDefaults ? t("provider.readyTitle") : t("provider.title")} +

+

+ {hasUsableDefaults + ? t("provider.readyDescription") + : t("provider.description")} +

+ + {hasUsableDefaults ? ( +
+ {usableDefaultEntries.map(({ kind, entry }) => { + const model = firstUsableModel(entry); + const isSelecting = selectingProviderId === entry.providerId; + return ( + + ); + })} +
+ ) : null} + + {!hasUsableDefaults && credentialLoading ? ( +

+ {t("provider.checking")} +

+ ) : null} + + {providerError ? ( +

+ {providerError} +

+ ) : null} + + {hasUsableDefaults && !showProviderSetup ? ( + <> +
+ + + ) : null} + + {showProviderSetup ? ( +
+ {hasUsableDefaults ? ( +

+ {t("provider.setupTitle")} +

+ ) : null} + {modelProviders.map((provider) => ( + onSave(provider.id, fields)} + onRemoveConfig={() => onRemove(provider.id)} + onCompleteNativeSetup={onCompleteNativeSetup} + saving={savingProviderIds.has(provider.id)} + inventorySyncing={syncingProviderIds.has(provider.id)} + inventoryWarning={inventoryWarnings.get(provider.id)} + /> + ))} + {canBrowseAllProviders ? ( + + ) : null} +
+ ) : null} + + {hasUsableDefaults ? ( +
+ +
+ ) : null} +
+ ); +} diff --git a/ui/goose2/src/features/onboarding/ui/ReadyStep.tsx b/ui/goose2/src/features/onboarding/ui/ReadyStep.tsx new file mode 100644 index 0000000000..1c1adee895 --- /dev/null +++ b/ui/goose2/src/features/onboarding/ui/ReadyStep.tsx @@ -0,0 +1,90 @@ +import { IconArrowRight, IconSparkles } from "@tabler/icons-react"; +import { formatCount } from "../lib/importCounts"; +import type { + OnboardingImportCounts, + SelectedSetup, + TFunctionLike, +} from "../types"; +import { Button } from "@/shared/ui/button"; + +interface ReadyStepProps { + availableSkillCount: number | null; + setupCounts: OnboardingImportCounts; + selectedSetup: SelectedSetup | null; + onBack: () => void; + onFinish: () => void; + t: TFunctionLike; +} + +export function ReadyStep({ + availableSkillCount, + setupCounts, + selectedSetup, + onBack, + onFinish, + t, +}: ReadyStepProps) { + const extensionCount = setupCounts.extensions; + const skillCount = availableSkillCount ?? setupCounts.skills; + + return ( +
+
+ +
+

+ {t("tour.title")} +

+

+ {selectedSetup?.modelName + ? t("tour.readyWithModel", { model: selectedSetup.modelName }) + : t("tour.description")} +

+ +
+
+

+ {t("tour.summary.defaultTitle")} +

+

+ {selectedSetup?.modelName ?? t("tour.summary.defaultFallback")} +

+

+ {t("tour.summary.defaultDescription")} +

+
+
+

+ {t("tour.summary.extensionsTitle")} +

+

+ {formatCount("extensions", extensionCount, t)} +

+

+ {t("tour.summary.extensionsDescription")} +

+
+
+

+ {t("tour.summary.skillsTitle")} +

+

+ {formatCount("skills", skillCount, t)} +

+

+ {t("tour.summary.skillsDescription")} +

+
+
+ +
+ + +
+
+ ); +} diff --git a/ui/goose2/src/features/providers/api/credentials.test.ts b/ui/goose2/src/features/providers/api/credentials.test.ts index 8de5e3324c..f2f9abc6f0 100644 --- a/ui/goose2/src/features/providers/api/credentials.test.ts +++ b/ui/goose2/src/features/providers/api/credentials.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + authenticateProviderConfig, checkAllProviderStatus, deleteProviderConfig, getProviderConfig, @@ -8,6 +9,7 @@ import { const mocks = vi.hoisted(() => ({ configRead: vi.fn(), + configAuthenticate: vi.fn(), configSave: vi.fn(), configDelete: vi.fn(), configStatus: vi.fn(), @@ -24,6 +26,7 @@ describe("provider credential API", () => { mocks.getClient.mockResolvedValue({ goose: { GooseProvidersConfigRead: mocks.configRead, + GooseProvidersConfigAuthenticate: mocks.configAuthenticate, GooseProvidersConfigSave: mocks.configSave, GooseProvidersConfigDelete: mocks.configDelete, GooseProvidersConfigStatus: mocks.configStatus, @@ -108,6 +111,28 @@ describe("provider credential API", () => { }); }); + it("authenticates provider config through ACP", async () => { + const response = { + status: { + providerId: "chatgpt_codex", + isConfigured: true, + }, + refresh: { + started: ["chatgpt_codex"], + skipped: [], + }, + }; + mocks.configAuthenticate.mockResolvedValue(response); + + await expect(authenticateProviderConfig("chatgpt_codex")).resolves.toEqual( + response, + ); + + expect(mocks.configAuthenticate).toHaveBeenCalledWith({ + providerId: "chatgpt_codex", + }); + }); + it("checks provider status through ACP", async () => { const statuses = [ { diff --git a/ui/goose2/src/features/providers/api/credentials.ts b/ui/goose2/src/features/providers/api/credentials.ts index 0d0024c14a..27e8db7100 100644 --- a/ui/goose2/src/features/providers/api/credentials.ts +++ b/ui/goose2/src/features/providers/api/credentials.ts @@ -28,6 +28,13 @@ export async function saveProviderConfig( return client.goose.GooseProvidersConfigSave({ providerId, fields }); } +export async function authenticateProviderConfig( + providerId: string, +): Promise { + const client = await getClient(); + return client.goose.GooseProvidersConfigAuthenticate({ providerId }); +} + export async function deleteProviderConfig( providerId: string, ): Promise { diff --git a/ui/goose2/src/features/providers/api/modelSetup.ts b/ui/goose2/src/features/providers/api/modelSetup.ts index f24da26105..5d1a664c06 100644 --- a/ui/goose2/src/features/providers/api/modelSetup.ts +++ b/ui/goose2/src/features/providers/api/modelSetup.ts @@ -1,5 +1,6 @@ -import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import type { ProviderConfigChangeResponse } from "@aaif/goose-sdk"; +import { authenticateProviderConfig } from "./credentials"; interface ModelSetupOutput { providerId: string; @@ -9,8 +10,9 @@ interface ModelSetupOutput { export async function authenticateModelProvider( providerId: string, providerLabel: string, -): Promise { - return invoke("authenticate_model_provider", { providerId, providerLabel }); +): Promise { + void providerLabel; + return authenticateProviderConfig(providerId); } export function onModelSetupOutput( diff --git a/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx b/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx index 03795896d1..e35144bf2c 100644 --- a/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx +++ b/ui/goose2/src/features/providers/hooks/useCredentials.test.tsx @@ -232,6 +232,42 @@ describe("useCredentials", () => { expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true); }); + it("uses native OAuth ACP result without an extra status refresh", async () => { + const refreshResponse = { + started: ["chatgpt_codex"], + skipped: [], + }; + mocks.checkAllProviderStatus.mockResolvedValueOnce([ + { + providerId: "chatgpt_codex", + isConfigured: false, + }, + ]); + + const { result } = renderHook(() => useCredentials()); + await waitFor(() => expect(result.current.loading).toBe(false)); + + await act(async () => { + await result.current.completeNativeSetup("chatgpt_codex", { + status: { + providerId: "chatgpt_codex", + isConfigured: true, + }, + refresh: refreshResponse, + }); + }); + + expect(mocks.refreshProviderInventory).not.toHaveBeenCalled(); + expect(mocks.checkAllProviderStatus).toHaveBeenCalledTimes(1); + expect(mocks.syncProviderInventory).toHaveBeenCalledWith( + ["chatgpt_codex"], + expect.objectContaining({ + initialRefresh: refreshResponse, + }), + ); + expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true); + }); + it("refreshes native OAuth status when initial inventory refresh fails", async () => { mocks.checkAllProviderStatus .mockResolvedValueOnce([ diff --git a/ui/goose2/src/features/providers/hooks/useCredentials.ts b/ui/goose2/src/features/providers/hooks/useCredentials.ts index 24d35b9d67..906098520b 100644 --- a/ui/goose2/src/features/providers/hooks/useCredentials.ts +++ b/ui/goose2/src/features/providers/hooks/useCredentials.ts @@ -6,6 +6,7 @@ import { type ProviderStatus, checkAllProviderStatus, } from "@/features/providers/api/credentials"; +import type { ProviderConfigChangeResponse } from "@aaif/goose-sdk"; import { notifyVoiceDictationConfigChanged } from "@/features/chat/lib/voiceInput"; import { syncProviderInventory, @@ -31,7 +32,10 @@ interface UseCredentialsReturn { getConfig: (providerId: string) => Promise; save: (providerId: string, fields: ProviderFieldSave[]) => Promise; remove: (providerId: string) => Promise; - completeNativeSetup: (providerId: string) => Promise; + completeNativeSetup: ( + providerId: string, + result?: ProviderConfigChangeResponse, + ) => Promise; } function errorMessage(error: unknown): string { @@ -234,7 +238,14 @@ export function useCredentials(): UseCredentialsReturn { ); const completeNativeSetup = useCallback( - async (providerId: string) => { + async (providerId: string, result?: ProviderConfigChangeResponse) => { + if (result) { + updateProviderStatus(result.status); + notifyVoiceDictationConfigChanged(); + startInventorySync(providerId, result.refresh); + return; + } + // Native OAuth returns only after the subprocess writes credentials. // Inventory refresh invalidates ACP's secret cache before status reads it. let initialRefresh: SyncProviderInventoryResult["refresh"] | undefined; @@ -247,7 +258,12 @@ export function useCredentials(): UseCredentialsReturn { notifyVoiceDictationConfigChanged(); startInventorySync(providerId, initialRefresh); }, - [refreshStatuses, setProviderInventoryWarning, startInventorySync], + [ + refreshStatuses, + setProviderInventoryWarning, + startInventorySync, + updateProviderStatus, + ], ); return { diff --git a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx index 3d5a0cc347..00cb0c0a9f 100644 --- a/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx +++ b/ui/goose2/src/features/settings/ui/AppearanceSettings.tsx @@ -78,7 +78,7 @@ export function AppearanceSettings() { {t(`appearance.theme.options.${option.value}`)} @@ -107,7 +107,7 @@ export function AppearanceSettings() { > {accentColorPreference === "default" && ( - + )} {ACCENT_COLORS.map((color) => ( @@ -148,7 +148,7 @@ export function AppearanceSettings() { {t(`appearance.density.options.${option.value}`)} diff --git a/ui/goose2/src/features/settings/ui/GeneralSettings.tsx b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx index a3515813e0..ea885c8940 100644 --- a/ui/goose2/src/features/settings/ui/GeneralSettings.tsx +++ b/ui/goose2/src/features/settings/ui/GeneralSettings.tsx @@ -1,4 +1,5 @@ import { useTranslation } from "react-i18next"; +import { useState } from "react"; import { type LocalePreference, useLocale } from "@/shared/i18n"; import { Select, @@ -8,6 +9,8 @@ import { SelectValue, } from "@/shared/ui/select"; import { SettingsPage } from "@/shared/ui/SettingsPage"; +import { Button } from "@/shared/ui/button"; +import { resetOnboardingCompletion } from "@/features/onboarding/hooks/useOnboardingGate"; function SettingRow({ label, @@ -34,6 +37,12 @@ function SettingRow({ export function GeneralSettings() { const { t } = useTranslation("settings"); const { preference, setLocalePreference, systemLocaleLabel } = useLocale(); + const [onboardingReset, setOnboardingReset] = useState(false); + + function resetOnboarding() { + resetOnboardingCompletion(); + setOnboardingReset(true); + } return ( @@ -61,6 +70,24 @@ export function GeneralSettings() { + + + + ); } diff --git a/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx index c29aed403e..169f6d20c5 100644 --- a/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx +++ b/ui/goose2/src/features/settings/ui/ModelProviderRow.tsx @@ -19,6 +19,7 @@ import { authenticateModelProvider, onModelSetupOutput, } from "@/features/providers/api/modelSetup"; +import type { ProviderConfigChangeResponse } from "@aaif/goose-sdk"; import type { ProviderDisplayInfo, ProviderField, @@ -51,7 +52,10 @@ interface ModelProviderRowProps { onGetConfig: (providerId: string) => Promise; onSaveFields: (fields: ProviderFieldSaveInput[]) => Promise; onRemoveConfig?: () => Promise; - onCompleteNativeSetup: (providerId: string) => Promise; + onCompleteNativeSetup: ( + providerId: string, + result?: ProviderConfigChangeResponse, + ) => Promise; saving?: boolean; inventorySyncing?: boolean; inventoryWarning?: string | null; @@ -165,7 +169,6 @@ export function ModelProviderRow({ setAuthenticating(true); setSetupError(""); setSetupOutput([]); - setupLineCounter.current = 0; setEditingKey(null); setError(""); setShowSavedState(false); @@ -173,10 +176,11 @@ export function ModelProviderRow({ const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput); try { - // The native connector exits after writing credentials; only then do we - // ask the credentials hook to refresh ACP inventory for this provider. - await authenticateModelProvider(provider.id, provider.nativeConnectQuery); - await onCompleteNativeSetup(provider.id); + const result = await authenticateModelProvider( + provider.id, + provider.nativeConnectQuery, + ); + await onCompleteNativeSetup(provider.id, result); } catch (nextError) { setSetupError( nextError instanceof Error diff --git a/ui/goose2/src/features/settings/ui/SettingsModal.tsx b/ui/goose2/src/features/settings/ui/SettingsModal.tsx index c21c6dd115..5d26f86bb7 100644 --- a/ui/goose2/src/features/settings/ui/SettingsModal.tsx +++ b/ui/goose2/src/features/settings/ui/SettingsModal.tsx @@ -136,7 +136,7 @@ export function SettingsModal({ className={cn( "w-full justify-start rounded-lg px-3 py-2 transition-all duration-600 ease-out max-[640px]:w-auto max-[640px]:shrink-0 max-[640px]:px-2.5", activeSection === item.id - ? "bg-background text-foreground shadow-sm hover:bg-background" + ? "bg-background text-foreground shadow-none hover:bg-background" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground duration-300", isLoaded ? "opacity-100 translate-x-0" diff --git a/ui/goose2/src/shared/i18n/constants.ts b/ui/goose2/src/shared/i18n/constants.ts index fd41c2053a..75b534d339 100644 --- a/ui/goose2/src/shared/i18n/constants.ts +++ b/ui/goose2/src/shared/i18n/constants.ts @@ -6,6 +6,7 @@ export const TRANSLATION_NAMESPACES = [ "common", "chat", "home", + "onboarding", "projects", "settings", "skills", diff --git a/ui/goose2/src/shared/i18n/i18n.ts b/ui/goose2/src/shared/i18n/i18n.ts index 85430a44a7..48daab32f7 100644 --- a/ui/goose2/src/shared/i18n/i18n.ts +++ b/ui/goose2/src/shared/i18n/i18n.ts @@ -32,6 +32,7 @@ const localeResourceLoaders = { common: () => import("./locales/en/common.json"), chat: () => import("./locales/en/chat.json"), home: () => import("./locales/en/home.json"), + onboarding: () => import("./locales/en/onboarding.json"), projects: () => import("./locales/en/projects.json"), settings: () => import("./locales/en/settings.json"), skills: () => import("./locales/en/skills.json"), @@ -43,6 +44,7 @@ const localeResourceLoaders = { common: () => import("./locales/es/common.json"), chat: () => import("./locales/es/chat.json"), home: () => import("./locales/es/home.json"), + onboarding: () => import("./locales/es/onboarding.json"), projects: () => import("./locales/es/projects.json"), settings: () => import("./locales/es/settings.json"), skills: () => import("./locales/es/skills.json"), diff --git a/ui/goose2/src/shared/i18n/locales/en/onboarding.json b/ui/goose2/src/shared/i18n/locales/en/onboarding.json new file mode 100644 index 0000000000..7f4b2feff9 --- /dev/null +++ b/ui/goose2/src/shared/i18n/locales/en/onboarding.json @@ -0,0 +1,67 @@ +{ + "windowTitle": "Goose setup", + "eyebrow": "First run", + "title": "Set up Goose", + "steps": { + "import": "Import", + "provider": "Provider", + "tour": "Ready" + }, + "counts": { + "providers": "{{count}} provider", + "providers_plural": "{{count}} providers", + "extensions": "{{count}} extension", + "extensions_plural": "{{count}} extensions", + "sessions": "{{count}} session", + "sessions_plural": "{{count}} sessions", + "skills": "{{count}} skill", + "skills_plural": "{{count}} skills", + "projects": "{{count}} project", + "projects_plural": "{{count}} projects", + "preferences": "{{count}} preference", + "preferences_plural": "{{count}} preferences", + "none": "No importable data" + }, + "import": { + "title": "Bring your setup with you", + "description": "Goose can bring over existing settings and tools it finds before you start chatting.", + "scanning": "Looking for existing Goose settings and Claude Desktop tools...", + "scanFailed": "Could not scan for existing setup.", + "emptyTitle": "No previous setup found", + "emptyDescription": "You can still configure a provider and start fresh.", + "failed": "Import failed.", + "skip": "Skip import", + "continue": "Import selected", + "next": "Continue" + }, + "provider": { + "title": "Choose a default", + "description": "Pick what Goose should use for new chats. Most people should choose an AI model service like ChatGPT, Anthropic, OpenAI, Google, or Ollama.", + "readyTitle": "Already connected", + "readyDescription": "Choose the default way Goose should start new chats. You can change this any time.", + "checking": "Checking provider status...", + "noneReady": "No usable provider is ready yet.", + "setupTitle": "Connect an AI model service", + "addDifferent": "Add a different provider", + "browseAll": "Show more", + "noModels": "This provider is configured, but no models are available yet.", + "selectFailed": "Could not select this provider.", + "useCurrent": "Keep current default" + }, + "tour": { + "title": "Ready to go", + "description": "Goose is ready. Here is what is available now.", + "readyWithModel": "Goose will start with {{model}}. You can switch models from Home whenever you need.", + "summary": { + "defaultTitle": "Default", + "defaultFallback": "Ready on Home", + "defaultDescription": "This is what Goose will use when you start a new chat.", + "extensionsTitle": "Extensions", + "extensionsDescription": "Connected tools are available in Settings. Imported Claude Desktop tools stay off until you enable them.", + "skillsTitle": "Skills", + "skillsDescription": "Personal skills Goose can see now. Project skills appear when you open a project." + }, + "back": "Back", + "finish": "Start using Goose" + } +} diff --git a/ui/goose2/src/shared/i18n/locales/en/settings.json b/ui/goose2/src/shared/i18n/locales/en/settings.json index 26b9f8aef4..050bed1dbc 100644 --- a/ui/goose2/src/shared/i18n/locales/en/settings.json +++ b/ui/goose2/src/shared/i18n/locales/en/settings.json @@ -161,6 +161,12 @@ "spanish": "Spanish", "system": "System default ({{language}})" }, + "onboarding": { + "description": "Show setup again the next time Goose opens.", + "label": "Onboarding", + "reset": "Reset onboarding", + "resetDescription": "Onboarding will appear the next time Goose opens." + }, "title": "General", "voiceInput": { "label": "Voice input", diff --git a/ui/goose2/src/shared/i18n/locales/es/onboarding.json b/ui/goose2/src/shared/i18n/locales/es/onboarding.json new file mode 100644 index 0000000000..980f2c1697 --- /dev/null +++ b/ui/goose2/src/shared/i18n/locales/es/onboarding.json @@ -0,0 +1,67 @@ +{ + "windowTitle": "Configuración de Goose", + "eyebrow": "Primer inicio", + "title": "Configura Goose", + "steps": { + "import": "Importar", + "provider": "Proveedor", + "tour": "Listo" + }, + "counts": { + "providers": "{{count}} proveedor", + "providers_plural": "{{count}} proveedores", + "extensions": "{{count}} extensión", + "extensions_plural": "{{count}} extensiones", + "sessions": "{{count}} sesión", + "sessions_plural": "{{count}} sesiones", + "skills": "{{count}} habilidad", + "skills_plural": "{{count}} habilidades", + "projects": "{{count}} proyecto", + "projects_plural": "{{count}} proyectos", + "preferences": "{{count}} preferencia", + "preferences_plural": "{{count}} preferencias", + "none": "No hay datos para importar" + }, + "import": { + "title": "Trae tu configuración", + "description": "Goose puede traer los ajustes y herramientas que encuentre antes de que empieces a chatear.", + "scanning": "Buscando ajustes de Goose y herramientas de Claude Desktop...", + "scanFailed": "No se pudo buscar configuración existente.", + "emptyTitle": "No se encontró configuración previa", + "emptyDescription": "Puedes configurar un proveedor y empezar desde cero.", + "failed": "La importación falló.", + "skip": "Omitir importación", + "continue": "Importar selección", + "next": "Continuar" + }, + "provider": { + "title": "Elige un valor predeterminado", + "description": "Elige qué debe usar Goose para los chats nuevos. La mayoría debería elegir un servicio de modelos de IA como ChatGPT, Anthropic, OpenAI, Google u Ollama.", + "readyTitle": "Ya conectado", + "readyDescription": "Elige cómo debe iniciar Goose los chats nuevos. Puedes cambiarlo cuando quieras.", + "checking": "Comprobando proveedores...", + "noneReady": "Todavía no hay un proveedor utilizable.", + "setupTitle": "Conecta un servicio de modelos de IA", + "addDifferent": "Añadir otro proveedor", + "browseAll": "Mostrar más", + "noModels": "Este proveedor está configurado, pero aún no hay modelos disponibles.", + "selectFailed": "No se pudo seleccionar este proveedor.", + "useCurrent": "Mantener valor predeterminado" + }, + "tour": { + "title": "Listo para empezar", + "description": "Goose está listo. Esto es lo que ya está disponible.", + "readyWithModel": "Goose empezará con {{model}}. Puedes cambiar de modelo desde Inicio cuando lo necesites.", + "summary": { + "defaultTitle": "Predeterminado", + "defaultFallback": "Listo en Inicio", + "defaultDescription": "Esto es lo que Goose usará cuando empieces un chat nuevo.", + "extensionsTitle": "Extensiones", + "extensionsDescription": "Las herramientas conectadas están disponibles en Configuración. Las herramientas importadas de Claude Desktop quedan desactivadas hasta que las habilites.", + "skillsTitle": "Habilidades", + "skillsDescription": "Habilidades personales que Goose puede ver ahora. Las habilidades de proyecto aparecen cuando abres un proyecto." + }, + "back": "Atrás", + "finish": "Empezar a usar Goose" + } +} diff --git a/ui/goose2/src/shared/i18n/locales/es/settings.json b/ui/goose2/src/shared/i18n/locales/es/settings.json index 5c192ca80b..1a9e44b0dd 100644 --- a/ui/goose2/src/shared/i18n/locales/es/settings.json +++ b/ui/goose2/src/shared/i18n/locales/es/settings.json @@ -161,6 +161,12 @@ "spanish": "Español", "system": "Predeterminado del sistema ({{language}})" }, + "onboarding": { + "description": "Muestra la configuración otra vez la próxima vez que se abra Goose.", + "label": "Onboarding", + "reset": "Restablecer onboarding", + "resetDescription": "El onboarding aparecerá la próxima vez que se abra Goose." + }, "title": "General", "voiceInput": { "label": "Entrada de voz", diff --git a/ui/goose2/src/shared/ui/button.tsx b/ui/goose2/src/shared/ui/button.tsx index c4e3113245..0feb0d6525 100644 --- a/ui/goose2/src/shared/ui/button.tsx +++ b/ui/goose2/src/shared/ui/button.tsx @@ -11,13 +11,13 @@ const buttonVariants = cva( variants: { variant: { default: - "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90", + "bg-primary text-primary-foreground shadow-none hover:bg-primary/90", destructive: - "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", + "bg-destructive text-destructive-foreground shadow-none hover:bg-destructive/90", "destructive-flat": "bg-destructive text-destructive-foreground shadow-none hover:bg-destructive/90", outline: - "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", + "border border-input bg-background shadow-none hover:bg-accent hover:text-accent-foreground", "outline-flat": "border border-border-soft bg-background shadow-none hover:bg-accent hover:text-accent-foreground", secondary: diff --git a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts index 39d22a2fb4..d5aa353be5 100644 --- a/ui/goose2/tests/e2e/fixtures/tauri-mock.ts +++ b/ui/goose2/tests/e2e/fixtures/tauri-mock.ts @@ -92,6 +92,26 @@ export function buildInitScript(options?: { }, ]; + localStorage.setItem( + "goose:onboarding:v1", + JSON.stringify({ + completedAt: new Date().toISOString(), + providerId: "openai", + modelId: "gpt-4.1", + }), + ); + localStorage.setItem("goose:defaultProvider", "goose"); + localStorage.setItem( + "goose:preferredModelsByAgent", + JSON.stringify({ + goose: { + providerId: "openai", + modelId: "gpt-4.1", + modelName: "GPT-4.1", + }, + }), + ); + const skillToSourceEntry = (s) => ({ type: "skill", name: s.name, @@ -185,6 +205,34 @@ export function buildInitScript(options?: { return jsonRpcResult(message.id, { entries: PROVIDER_INVENTORY }); case "_goose/providers/inventory/refresh": return jsonRpcResult(message.id, { started: [], skipped: [] }); + case "_goose/defaults/read": + case "_goose/defaults/save": + return jsonRpcResult(message.id, { + providerId: message.params?.providerId ?? "openai", + modelId: message.params?.modelId ?? "gpt-4.1", + }); + case "_goose/onboarding/import/scan": + return jsonRpcResult(message.id, { candidates: [] }); + case "_goose/onboarding/import/apply": + return jsonRpcResult(message.id, { + imported: { + providers: 0, + extensions: 0, + sessions: 0, + skills: 0, + projects: 0, + preferences: 0, + }, + skipped: { + providers: 0, + extensions: 0, + sessions: 0, + skills: 0, + projects: 0, + preferences: 0, + }, + warnings: [], + }); case "_goose/working_dir/update": case "goose/working_dir/update": return jsonRpcResult(message.id, {}); diff --git a/ui/goose2/tests/e2e/smoke.spec.ts b/ui/goose2/tests/e2e/smoke.spec.ts index 5623279be5..808400d4ae 100644 --- a/ui/goose2/tests/e2e/smoke.spec.ts +++ b/ui/goose2/tests/e2e/smoke.spec.ts @@ -1,7 +1,7 @@ -import { expect, test } from "@playwright/test"; +import { expect, test } from "./fixtures/tauri-mock"; test.describe("Smoke tests", () => { - test("app loads and shows home screen", async ({ page }) => { + test("app loads and shows home screen", async ({ tauriMocked: page }) => { await page.goto("/"); // Wait for the app to render — greeting should appear @@ -10,14 +10,16 @@ test.describe("Smoke tests", () => { ).toBeVisible({ timeout: 10_000 }); }); - test("home screen shows clock", async ({ page }) => { + test("home screen shows clock", async ({ tauriMocked: page }) => { await page.goto("/"); // Should show AM or PM once the clock renders await expect(page.getByText(/[AP]M/)).toBeVisible({ timeout: 10_000 }); }); - test("home screen shows chat input placeholder", async ({ page }) => { + test("home screen shows chat input placeholder", async ({ + tauriMocked: page, + }) => { await page.goto("/"); await expect( diff --git a/ui/sdk/src/generated/client.gen.ts b/ui/sdk/src/generated/client.gen.ts index 93374a5bf0..686c4f7988 100644 --- a/ui/sdk/src/generated/client.gen.ts +++ b/ui/sdk/src/generated/client.gen.ts @@ -23,6 +23,7 @@ import type { CustomProviderUpdateResponse, DefaultsReadRequest, DefaultsReadResponse, + DefaultsSaveRequest, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, @@ -59,6 +60,10 @@ import type { ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, + OnboardingImportApplyRequest, + OnboardingImportApplyResponse, + OnboardingImportScanRequest, + OnboardingImportScanResponse, PreferencesReadRequest, PreferencesReadResponse, PreferencesRemoveRequest, @@ -67,6 +72,7 @@ import type { ProviderCatalogListResponse, ProviderCatalogTemplateRequest, ProviderCatalogTemplateResponse, + ProviderConfigAuthenticateRequest, ProviderConfigChangeResponse, ProviderConfigDeleteRequest, ProviderConfigReadRequest, @@ -111,6 +117,8 @@ import { zImportSourcesResponse, zListProvidersResponse, zListSourcesResponse, + zOnboardingImportApplyResponse, + zOnboardingImportScanResponse, zPreferencesReadResponse, zProviderCatalogListResponse, zProviderCatalogTemplateResponse, @@ -342,6 +350,18 @@ export class GooseExtClient { ) as ProviderConfigChangeResponse; } + async GooseProvidersConfigAuthenticate( + params: ProviderConfigAuthenticateRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/providers/config/authenticate", + params, + ); + return zProviderConfigChangeResponse.parse( + raw, + ) as ProviderConfigChangeResponse; + } + async GoosePreferencesRead( params: PreferencesReadRequest, ): Promise { @@ -366,6 +386,37 @@ export class GooseExtClient { return zDefaultsReadResponse.parse(raw) as DefaultsReadResponse; } + async GooseDefaultsSave( + params: DefaultsSaveRequest, + ): Promise { + const raw = await this.conn.extMethod("_goose/defaults/save", params); + return zDefaultsReadResponse.parse(raw) as DefaultsReadResponse; + } + + async GooseOnboardingImportScan( + params: OnboardingImportScanRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/onboarding/import/scan", + params, + ); + return zOnboardingImportScanResponse.parse( + raw, + ) as OnboardingImportScanResponse; + } + + async GooseOnboardingImportApply( + params: OnboardingImportApplyRequest, + ): Promise { + const raw = await this.conn.extMethod( + "_goose/onboarding/import/apply", + params, + ); + return zOnboardingImportApplyResponse.parse( + raw, + ) as OnboardingImportApplyResponse; + } + async GooseSessionExport( params: ExportSessionRequest, ): Promise { diff --git a/ui/sdk/src/generated/index.ts b/ui/sdk/src/generated/index.ts index da10fac48a..e4ecad2f29 100644 --- a/ui/sdk/src/generated/index.ts +++ b/ui/sdk/src/generated/index.ts @@ -1,6 +1,6 @@ // This file is auto-generated by @hey-api/openapi-ts -export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, CreateSourceRequest, CreateSourceResponse, CustomProviderConfigDto, CustomProviderCreateRequest, CustomProviderCreateResponse, CustomProviderDeleteRequest, CustomProviderDeleteResponse, CustomProviderReadRequest, CustomProviderReadResponse, CustomProviderUpdateRequest, CustomProviderUpdateResponse, DefaultsReadRequest, DefaultsReadResponse, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationSecretDeleteRequest, DictationSecretSaveRequest, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, GooseToolCallRequest, GooseToolCallResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, PreferenceKey, PreferencesReadRequest, PreferencesReadResponse, PreferencesRemoveRequest, PreferencesSaveRequest, PreferenceValue, ProviderCatalogListRequest, ProviderCatalogListResponse, ProviderCatalogTemplateRequest, ProviderCatalogTemplateResponse, ProviderConfigChangeResponse, ProviderConfigDeleteRequest, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest, ProviderConfigReadResponse, ProviderConfigSaveRequest, ProviderConfigStatusDto, ProviderConfigStatusRequest, ProviderConfigStatusResponse, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest, ProviderSetupCatalogListResponse, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveExtensionRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest } from './types.gen.js'; +export type { AddConfigExtensionRequest, AddExtensionRequest, ArchiveSessionRequest, CreateSourceRequest, CreateSourceResponse, CustomProviderConfigDto, CustomProviderCreateRequest, CustomProviderCreateResponse, CustomProviderDeleteRequest, CustomProviderDeleteResponse, CustomProviderReadRequest, CustomProviderReadResponse, CustomProviderUpdateRequest, CustomProviderUpdateResponse, DefaultsReadRequest, DefaultsReadResponse, DefaultsSaveRequest, DeleteSessionRequest, DeleteSourceRequest, DictationConfigRequest, DictationConfigResponse, DictationDownloadProgress, DictationLocalModelStatus, DictationModelCancelRequest, DictationModelDeleteRequest, DictationModelDownloadProgressRequest, DictationModelDownloadProgressResponse, DictationModelDownloadRequest, DictationModelOption, DictationModelSelectRequest, DictationModelsListRequest, DictationModelsListResponse, DictationProviderStatusEntry, DictationSecretDeleteRequest, DictationSecretSaveRequest, DictationTranscribeRequest, DictationTranscribeResponse, EmptyResponse, ExportSessionRequest, ExportSessionResponse, ExportSourceRequest, ExportSourceResponse, ExtRequest, ExtResponse, GetExtensionsRequest, GetExtensionsResponse, GetSessionExtensionsRequest, GetSessionExtensionsResponse, GetToolsRequest, GetToolsResponse, GooseToolCallRequest, GooseToolCallResponse, ImportSessionRequest, ImportSessionResponse, ImportSourcesRequest, ImportSourcesResponse, ListProvidersRequest, ListProvidersResponse, ListSourcesRequest, ListSourcesResponse, OnboardingImportApplyRequest, OnboardingImportApplyResponse, OnboardingImportCandidate, OnboardingImportCounts, OnboardingImportScanRequest, OnboardingImportScanResponse, OnboardingImportSourceKind, PreferenceKey, PreferencesReadRequest, PreferencesReadResponse, PreferencesRemoveRequest, PreferencesSaveRequest, PreferenceValue, ProviderCatalogListRequest, ProviderCatalogListResponse, ProviderCatalogTemplateRequest, ProviderCatalogTemplateResponse, ProviderConfigAuthenticateRequest, ProviderConfigChangeResponse, ProviderConfigDeleteRequest, ProviderConfigFieldUpdate, ProviderConfigFieldValueDto, ProviderConfigKey, ProviderConfigReadRequest, ProviderConfigReadResponse, ProviderConfigSaveRequest, ProviderConfigStatusDto, ProviderConfigStatusRequest, ProviderConfigStatusResponse, ProviderInventoryEntryDto, ProviderInventoryModelDto, ProviderSetupCatalogEntryDto, ProviderSetupCatalogListRequest, ProviderSetupCatalogListResponse, ProviderSetupCategoryDto, ProviderSetupFieldDto, ProviderSetupGroupDto, ProviderSetupMethodDto, ProviderTemplateCapabilitiesDto, ProviderTemplateCatalogEntryDto, ProviderTemplateDto, ProviderTemplateModelDto, ReadResourceRequest, ReadResourceResponse, RefreshProviderInventoryRequest, RefreshProviderInventoryResponse, RefreshProviderInventorySkipDto, RefreshProviderInventorySkipReasonDto, RemoveConfigExtensionRequest, RemoveExtensionRequest, RenameSessionRequest, SourceEntry, SourceType, ToggleConfigExtensionRequest, UnarchiveSessionRequest, UpdateSessionProjectRequest, UpdateSourceRequest, UpdateSourceResponse, UpdateWorkingDirRequest } from './types.gen.js'; export const GOOSE_EXT_METHODS = [ { @@ -128,6 +128,11 @@ export const GOOSE_EXT_METHODS = [ requestType: "ProviderConfigDeleteRequest", responseType: "ProviderConfigChangeResponse", }, + { + method: "_goose/providers/config/authenticate", + requestType: "ProviderConfigAuthenticateRequest", + responseType: "ProviderConfigChangeResponse", + }, { method: "_goose/preferences/read", requestType: "PreferencesReadRequest", @@ -148,6 +153,21 @@ export const GOOSE_EXT_METHODS = [ requestType: "DefaultsReadRequest", responseType: "DefaultsReadResponse", }, + { + method: "_goose/defaults/save", + requestType: "DefaultsSaveRequest", + responseType: "DefaultsReadResponse", + }, + { + method: "_goose/onboarding/import/scan", + requestType: "OnboardingImportScanRequest", + responseType: "OnboardingImportScanResponse", + }, + { + method: "_goose/onboarding/import/apply", + requestType: "OnboardingImportApplyRequest", + responseType: "OnboardingImportApplyResponse", + }, { method: "_goose/session/export", requestType: "ExportSessionRequest", diff --git a/ui/sdk/src/generated/types.gen.ts b/ui/sdk/src/generated/types.gen.ts index c4b40c960b..5fd9f3cd51 100644 --- a/ui/sdk/src/generated/types.gen.ts +++ b/ui/sdk/src/generated/types.gen.ts @@ -567,6 +567,13 @@ export type ProviderConfigDeleteRequest = { providerId: string; }; +/** + * Run a provider-owned native authentication flow and start an inventory refresh when supported. + */ +export type ProviderConfigAuthenticateRequest = { + providerId: string; +}; + /** * Read allowlisted user preferences. Empty `keys` means all supported preferences. */ @@ -611,6 +618,66 @@ export type DefaultsReadResponse = { modelId?: string | null; }; +/** + * Save Goose default provider and model configuration. + */ +export type DefaultsSaveRequest = { + providerId: string; + modelId?: string | null; +}; + +/** + * Scan for existing Goose and compatible app data that onboarding can import. + */ +export type OnboardingImportScanRequest = { + /** + * Empty means all supported import sources. + */ + sources?: Array; +}; + +/** + * Sources that onboarding knows how to discover and import. + */ +export type OnboardingImportSourceKind = 'goose_config' | 'claude_desktop'; + +export type OnboardingImportScanResponse = { + candidates: Array; +}; + +export type OnboardingImportCandidate = { + id: string; + sourceKind: OnboardingImportSourceKind; + displayName: string; + path: string; + counts: OnboardingImportCounts; + warnings?: Array; +}; + +export type OnboardingImportCounts = { + providers: number; + extensions: number; + sessions: number; + skills: number; + projects: number; + preferences: number; +}; + +/** + * Import selected onboarding candidates. + */ +export type OnboardingImportApplyRequest = { + candidateIds?: Array; + enableImportedExtensions?: boolean; +}; + +export type OnboardingImportApplyResponse = { + imported: OnboardingImportCounts; + skipped: OnboardingImportCounts; + warnings?: Array; + providerDefaults?: DefaultsReadResponse | null; +}; + /** * Export a session as a JSON string. */ @@ -944,14 +1011,14 @@ export type DictationModelSelectRequest = { export type ExtRequest = { id: string; method: string; - params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | GooseToolCallRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | ProviderCatalogListRequest | ProviderSetupCatalogListRequest | ProviderCatalogTemplateRequest | CustomProviderCreateRequest | CustomProviderReadRequest | CustomProviderUpdateRequest | CustomProviderDeleteRequest | RefreshProviderInventoryRequest | ProviderConfigReadRequest | ProviderConfigStatusRequest | ProviderConfigSaveRequest | ProviderConfigDeleteRequest | PreferencesReadRequest | PreferencesSaveRequest | PreferencesRemoveRequest | DefaultsReadRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationSecretSaveRequest | DictationSecretDeleteRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { + params?: AddExtensionRequest | RemoveExtensionRequest | GetToolsRequest | GooseToolCallRequest | ReadResourceRequest | UpdateWorkingDirRequest | DeleteSessionRequest | GetExtensionsRequest | AddConfigExtensionRequest | RemoveConfigExtensionRequest | ToggleConfigExtensionRequest | GetSessionExtensionsRequest | ListProvidersRequest | ProviderCatalogListRequest | ProviderSetupCatalogListRequest | ProviderCatalogTemplateRequest | CustomProviderCreateRequest | CustomProviderReadRequest | CustomProviderUpdateRequest | CustomProviderDeleteRequest | RefreshProviderInventoryRequest | ProviderConfigReadRequest | ProviderConfigStatusRequest | ProviderConfigSaveRequest | ProviderConfigDeleteRequest | ProviderConfigAuthenticateRequest | PreferencesReadRequest | PreferencesSaveRequest | PreferencesRemoveRequest | DefaultsReadRequest | DefaultsSaveRequest | OnboardingImportScanRequest | OnboardingImportApplyRequest | ExportSessionRequest | ImportSessionRequest | UpdateSessionProjectRequest | RenameSessionRequest | ArchiveSessionRequest | UnarchiveSessionRequest | CreateSourceRequest | ListSourcesRequest | UpdateSourceRequest | DeleteSourceRequest | ExportSourceRequest | ImportSourcesRequest | DictationTranscribeRequest | DictationConfigRequest | DictationSecretSaveRequest | DictationSecretDeleteRequest | DictationModelsListRequest | DictationModelDownloadRequest | DictationModelDownloadProgressRequest | DictationModelCancelRequest | DictationModelDeleteRequest | DictationModelSelectRequest | { [key: string]: unknown; } | null; }; export type ExtResponse = { id: string; - result?: EmptyResponse | GetToolsResponse | GooseToolCallResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | ProviderCatalogListResponse | ProviderSetupCatalogListResponse | ProviderCatalogTemplateResponse | CustomProviderCreateResponse | CustomProviderReadResponse | CustomProviderUpdateResponse | CustomProviderDeleteResponse | RefreshProviderInventoryResponse | ProviderConfigReadResponse | ProviderConfigStatusResponse | ProviderConfigChangeResponse | PreferencesReadResponse | DefaultsReadResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; + result?: EmptyResponse | GetToolsResponse | GooseToolCallResponse | ReadResourceResponse | GetExtensionsResponse | GetSessionExtensionsResponse | ListProvidersResponse | ProviderCatalogListResponse | ProviderSetupCatalogListResponse | ProviderCatalogTemplateResponse | CustomProviderCreateResponse | CustomProviderReadResponse | CustomProviderUpdateResponse | CustomProviderDeleteResponse | RefreshProviderInventoryResponse | ProviderConfigReadResponse | ProviderConfigStatusResponse | ProviderConfigChangeResponse | PreferencesReadResponse | DefaultsReadResponse | OnboardingImportScanResponse | OnboardingImportApplyResponse | ExportSessionResponse | ImportSessionResponse | CreateSourceResponse | ListSourcesResponse | UpdateSourceResponse | ExportSourceResponse | ImportSourcesResponse | DictationTranscribeResponse | DictationConfigResponse | DictationModelsListResponse | DictationModelDownloadProgressResponse | unknown; } | { error: { code: number; diff --git a/ui/sdk/src/generated/zod.gen.ts b/ui/sdk/src/generated/zod.gen.ts index 542713de9c..c05491143a 100644 --- a/ui/sdk/src/generated/zod.gen.ts +++ b/ui/sdk/src/generated/zod.gen.ts @@ -554,6 +554,13 @@ export const zProviderConfigDeleteRequest = z.object({ providerId: z.string() }); +/** + * Run a provider-owned native authentication flow and start an inventory refresh when supported. + */ +export const zProviderConfigAuthenticateRequest = z.object({ + providerId: z.string() +}); + export const zPreferenceKey = z.enum([ 'autoCompactThreshold', 'voiceAutoSubmitPhrases', @@ -607,6 +614,69 @@ export const zDefaultsReadResponse = z.object({ ]).optional() }); +/** + * Save Goose default provider and model configuration. + */ +export const zDefaultsSaveRequest = z.object({ + providerId: z.string(), + modelId: z.union([ + z.string(), + z.null() + ]).optional() +}); + +/** + * Sources that onboarding knows how to discover and import. + */ +export const zOnboardingImportSourceKind = z.enum(['goose_config', 'claude_desktop']); + +/** + * Scan for existing Goose and compatible app data that onboarding can import. + */ +export const zOnboardingImportScanRequest = z.object({ + sources: z.array(zOnboardingImportSourceKind).optional().default([]) +}); + +export const zOnboardingImportCounts = z.object({ + providers: z.number().int().gte(0), + extensions: z.number().int().gte(0), + sessions: z.number().int().gte(0), + skills: z.number().int().gte(0), + projects: z.number().int().gte(0), + preferences: z.number().int().gte(0) +}); + +export const zOnboardingImportCandidate = z.object({ + id: z.string(), + sourceKind: zOnboardingImportSourceKind, + displayName: z.string(), + path: z.string(), + counts: zOnboardingImportCounts, + warnings: z.array(z.string()).optional().default([]) +}); + +export const zOnboardingImportScanResponse = z.object({ + candidates: z.array(zOnboardingImportCandidate) +}); + +/** + * Import selected onboarding candidates. + */ +export const zOnboardingImportApplyRequest = z.object({ + candidateIds: z.array(z.string()).optional().default([]), + enableImportedExtensions: z.boolean().optional().default(false) +}); + +export const zOnboardingImportApplyResponse = z.object({ + imported: zOnboardingImportCounts, + skipped: zOnboardingImportCounts, + warnings: z.array(z.string()).optional().default([]), + providerDefaults: z.union([ + zDefaultsReadResponse, + z.null() + ]).optional() +}); + /** * Export a session as a JSON string. */ @@ -982,10 +1052,14 @@ export const zExtRequest = z.object({ zProviderConfigStatusRequest, zProviderConfigSaveRequest, zProviderConfigDeleteRequest, + zProviderConfigAuthenticateRequest, zPreferencesReadRequest, zPreferencesSaveRequest, zPreferencesRemoveRequest, zDefaultsReadRequest, + zDefaultsSaveRequest, + zOnboardingImportScanRequest, + zOnboardingImportApplyRequest, zExportSessionRequest, zImportSessionRequest, zUpdateSessionProjectRequest, @@ -1041,6 +1115,8 @@ export const zExtResponse = z.union([ zProviderConfigChangeResponse, zPreferencesReadResponse, zDefaultsReadResponse, + zOnboardingImportScanResponse, + zOnboardingImportApplyResponse, zExportSessionResponse, zImportSessionResponse, zCreateSourceResponse,