add provider-first onboarding (#9039)

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi
2026-05-06 07:08:31 -07:00
committed by GitHub
parent f9d0c6416d
commit 6fc44bf0ed
51 changed files with 3589 additions and 37 deletions
+97
View File
@@ -237,6 +237,92 @@ pub struct DefaultsReadResponse {
pub model_id: Option<String>,
}
/// 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<String>,
}
/// 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<String>,
}
/// 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<OnboardingImportSourceKind>,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
pub struct OnboardingImportScanResponse {
pub candidates: Vec<OnboardingImportCandidate>,
}
/// 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<String>,
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider_defaults: Option<DefaultsReadResponse>,
}
/// 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 {
+20
View File
@@ -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",
+250 -1
View File
@@ -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": [
{
+1
View File
@@ -72,6 +72,7 @@ mod custom_dispatch;
mod dictation;
mod dispatch;
mod extensions;
mod onboarding;
mod providers;
mod resources;
mod sessions;
+66
View File
@@ -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<DefaultsReadResponse, sacp::Error> {
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 {
@@ -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<ProviderConfigChangeResponse, sacp::Error> {
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<DefaultsReadResponse, sacp::Error> {
self.on_defaults_save(req).await
}
#[custom_method(OnboardingImportScanRequest)]
async fn dispatch_onboarding_import_scan(
&self,
req: OnboardingImportScanRequest,
) -> Result<OnboardingImportScanResponse, sacp::Error> {
self.on_onboarding_import_scan(req).await
}
#[custom_method(OnboardingImportApplyRequest)]
async fn dispatch_onboarding_import_apply(
&self,
req: OnboardingImportApplyRequest,
) -> Result<OnboardingImportApplyResponse, sacp::Error> {
self.on_onboarding_import_apply(req).await
}
#[custom_method(ExportSessionRequest)]
async fn dispatch_export_session(
&self,
+695
View File
@@ -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<String, ClaudeMcpServer>,
}
#[derive(Debug, Deserialize)]
struct ClaudeMcpServer {
#[serde(default)]
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
impl GooseAcpAgent {
pub(super) async fn on_onboarding_import_scan(
&self,
req: OnboardingImportScanRequest,
) -> Result<OnboardingImportScanResponse, sacp::Error> {
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<OnboardingImportApplyResponse, sacp::Error> {
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<String>,
provider_defaults: DefaultsReadResponse,
}
fn source_filter(sources: &[OnboardingImportSourceKind]) -> HashSet<OnboardingImportSourceKind> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
fn scan_goose_config_candidate(path: &Path) -> Option<OnboardingImportCandidate> {
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<OnboardingImportCandidate> {
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<Mapping> {
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<ApplyResult> {
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<String> {
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<ImportPairCount> {
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<Mapping> {
match target_config.get_param::<Mapping>("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<u32> {
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<ImportPairCount> {
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<ApplyResult> {
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<String, ClaudeMcpServer>, Vec<String>)> {
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::<Mapping>("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::<String>("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());
}
}
+31
View File
@@ -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<ProviderConfigChangeResponse, sacp::Error> {
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 })
}
}
@@ -943,6 +943,10 @@ impl ProviderDef for ChatGptCodexProvider {
) -> BoxFuture<'static, Result<Self::Provider>> {
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"),
@@ -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::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY")
+27 -4
View File
@@ -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 (
<div className="flex h-screen w-screen items-center justify-center bg-background text-foreground">
<Spinner className="size-5 text-brand" />
</div>
);
}
if (onboardingGate.shouldShowOnboarding) {
return (
<OnboardingFlow
readiness={onboardingGate.readiness}
onComplete={(setup) => {
onboardingGate.completeOnboarding(setup);
setActiveView("home");
}}
/>
);
}
return (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<TopBar />
+22 -2
View File
@@ -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<unknown>(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 };
}
@@ -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"
>
<X className="size-3" />
</Button>
@@ -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}
@@ -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<DefaultsReadResponse> {
const client = await getClient();
return client.goose.GooseDefaultsRead({});
}
export async function saveDefaults(
params: DefaultsSaveRequest,
): Promise<DefaultsReadResponse> {
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<OnboardingImportApplyResponse> {
const client = await getClient();
return client.goose.GooseOnboardingImportApply(params);
}
@@ -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<OnboardingStep>("import");
const [selectedSetup, setSelectedSetup] = useState<SelectedSetup | null>(
() =>
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,
},
};
}
@@ -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>,
): 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",
});
});
});
@@ -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<OnboardingCompletion>;
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<OnboardingCompletion | null>(
readCompletion,
);
const modelProviderIds = useMemo(
() =>
new Set(
filterModelProvidersForDistro(getModelProviders(), distro).map(
(provider) => provider.id,
),
),
[distro],
);
const readiness = useMemo<OnboardingReadiness>(() => {
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<OnboardingCompletion, "completedAt">) => {
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,
};
}
@@ -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<OnboardingImportCandidate[]>([]);
const [selectedCandidateIds, setSelectedCandidateIds] = useState<Set<string>>(
() => new Set(),
);
const [scanLoading, setScanLoading] = useState(true);
const [importing, setImporting] = useState(false);
const [importError, setImportError] = useState("");
const [setupCounts, setSetupCounts] =
useState<OnboardingImportCounts>(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(),
},
};
}
@@ -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<string>(),
loading: false,
savingProviderIds: new Set<string>(),
syncingProviderIds: new Set<string>(),
inventoryWarnings: new Map<string, string[]>(),
getConfig: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
completeNativeSetup: vi.fn(),
}),
}));
function providerEntry(
overrides: Partial<ProviderInventoryEntryDto>,
): 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> = {},
): 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();
});
});
@@ -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<string | null>(
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<UsableDefaultEntry[]>(
() => [
...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(),
};
}
@@ -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<number | null>(
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 };
}
@@ -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");
}
@@ -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]
);
}
@@ -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, unknown>,
) => string;
@@ -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<string>;
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 (
<section className="flex w-full flex-col items-center">
<div className="flex size-14 items-center justify-center rounded-[14px] bg-muted text-muted-foreground">
<IconDatabaseImport className="size-6" strokeWidth={1.75} />
</div>
<h2 className="mt-6 text-[22px] font-semibold tracking-tight text-foreground">
{t("import.title")}
</h2>
<p className="mt-2 max-w-[420px] text-sm leading-6 text-muted-foreground">
{t("import.description")}
</p>
<div className="mt-8 w-full space-y-2">
{scanLoading ? (
<div className="flex items-center justify-center gap-2 rounded-[14px] border border-border px-4 py-5 text-sm text-muted-foreground">
<Spinner className="size-4 text-foreground" />
{t("import.scanning")}
</div>
) : !hasCandidates ? (
<div className="rounded-[14px] border border-border px-4 py-5 text-left">
<p className="text-sm font-medium">{t("import.emptyTitle")}</p>
<p className="mt-1 text-sm text-muted-foreground">
{t("import.emptyDescription")}
</p>
</div>
) : (
candidates.map((candidate) => {
const warnings = candidate.warnings ?? [];
const checked = selectedCandidateIds.has(candidate.id);
return (
<label
key={candidate.id}
className={cn(
"flex cursor-pointer items-center gap-3 rounded-[14px] border bg-background px-4 py-3 text-left transition-colors",
checked
? "border-foreground/80"
: "border-border hover:border-foreground/30",
)}
>
<input
type="checkbox"
className="sr-only"
checked={checked}
onChange={() => onToggleCandidate(candidate.id)}
/>
<span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-muted">
<IconDatabaseImport
className="size-5 text-foreground"
strokeWidth={1.75}
/>
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{candidate.displayName}
</span>
<span className="mt-0.5 truncate text-xs text-muted-foreground">
{formatCandidateCounts(candidate, t)}
</span>
{warnings.length > 0 ? (
<span className="mt-1 block text-xs text-text-warning">
{warnings.join(" ")}
</span>
) : null}
</span>
<span
aria-hidden="true"
className={cn(
"flex size-5 shrink-0 items-center justify-center rounded-full transition-colors",
checked
? "bg-foreground text-background"
: "border border-border",
)}
>
{checked ? (
<IconCheck className="size-3" strokeWidth={3} />
) : null}
</span>
</label>
);
})
)}
</div>
{importError ? (
<p
role="alert"
className="mt-4 w-full rounded-[10px] bg-danger/10 px-3 py-2 text-xs text-danger"
>
{importError}
</p>
) : null}
<div className="mt-6 flex items-center justify-center gap-3">
<Button type="button" variant="ghost" onClick={onSkip}>
{t("import.skip")}
</Button>
<Button
type="button"
onClick={onContinue}
disabled={importing || scanLoading}
rightIcon={!importing ? <IconArrowRight /> : undefined}
>
{importing ? <Spinner className="size-3.5 text-current" /> : null}
{!hasCandidates ? t("import.next") : t("import.continue")}
</Button>
</div>
</section>
);
}
@@ -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 (
<div className="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
<div
className="flex h-11 shrink-0 items-center justify-center"
data-tauri-drag-region
>
<div className="text-xs font-medium text-foreground">
{t("onboarding:windowTitle")}
</div>
</div>
<main className="min-h-0 flex-1 overflow-y-auto">
<div className="flex min-h-full w-full flex-col items-center justify-center px-6 py-10">
<div className="flex w-full max-w-[520px] flex-col items-center text-center">
{flow.step === "import" ? (
<ImportStep {...flow.importStep} t={t} />
) : null}
{flow.step === "provider" ? (
<ProviderStep {...flow.providerStep} t={t} />
) : null}
{flow.step === "tour" ? (
<ReadyStep {...flow.readyStep} t={t} />
) : null}
</div>
</div>
</main>
<div className="flex h-14 shrink-0 items-center justify-center gap-1.5">
{flow.stepOrder.map((item) => (
<span
key={item}
aria-hidden="true"
className={cn(
"size-1.5 rounded-full transition-colors",
item === flow.step ? "bg-foreground" : "bg-border",
)}
/>
))}
</div>
</div>
);
}
@@ -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<typeof getModelProviders>;
canBrowseAllProviders: boolean;
usableDefaultEntries: UsableDefaultEntry[];
configuredIds: Set<string>;
savingProviderIds: Set<string>;
syncingProviderIds: Set<string>;
inventoryWarnings: Map<string, string>;
selectingProviderId: string | null;
providerError: string;
onGetConfig: ComponentProps<typeof ModelProviderRow>["onGetConfig"];
onSave: (
providerId: string,
fields: ProviderFieldSaveInput[],
) => Promise<void>;
onRemove: (providerId: string) => Promise<void>;
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 (
<section className="flex w-full flex-col items-center">
<div
className={cn(
"flex size-14 items-center justify-center rounded-[14px]",
hasUsableDefaults
? "bg-green-100/40 text-green-300"
: "bg-muted text-muted-foreground",
)}
>
{hasUsableDefaults ? (
<IconCheck className="size-7" strokeWidth={2.25} />
) : (
<IconPlugConnected className="size-6" strokeWidth={1.75} />
)}
</div>
<h2 className="mt-6 text-[22px] font-semibold tracking-tight text-foreground">
{hasUsableDefaults ? t("provider.readyTitle") : t("provider.title")}
</h2>
<p className="mt-2 max-w-[420px] text-sm leading-6 text-muted-foreground">
{hasUsableDefaults
? t("provider.readyDescription")
: t("provider.description")}
</p>
{hasUsableDefaults ? (
<div className="mt-8 grid w-full gap-2 sm:grid-cols-2">
{usableDefaultEntries.map(({ kind, entry }) => {
const model = firstUsableModel(entry);
const isSelecting = selectingProviderId === entry.providerId;
return (
<button
key={`${kind}:${entry.providerId}`}
type="button"
onClick={() =>
kind === "model"
? onSelectModelProvider(entry)
: onSelectAgentProvider(entry)
}
disabled={isSelecting}
className="flex w-full items-center gap-3 rounded-[14px] border border-border px-3 py-3 text-left transition-colors hover:bg-muted/40 disabled:opacity-60"
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-muted text-foreground">
{getProviderIcon(entry.providerId, "size-4") ?? (
<IconPlugConnected className="size-4" strokeWidth={1.75} />
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{entry.providerName}
</span>
<span className="truncate text-xs text-muted-foreground">
{model?.name ?? entry.defaultModel}
</span>
</span>
{isSelecting ? (
<Spinner className="size-4 shrink-0 text-foreground" />
) : null}
</button>
);
})}
</div>
) : null}
{!hasUsableDefaults && credentialLoading ? (
<p className="mt-8 w-full rounded-[14px] border border-border px-3 py-3 text-sm text-muted-foreground">
{t("provider.checking")}
</p>
) : null}
{providerError ? (
<p
role="alert"
className="mt-4 w-full rounded-[10px] bg-danger/10 px-3 py-2 text-xs text-danger"
>
{providerError}
</p>
) : null}
{hasUsableDefaults && !showProviderSetup ? (
<>
<div className="mt-6 h-px w-full bg-border" />
<button
type="button"
onClick={() => setShowProviderSetup(true)}
className="mt-4 inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<span aria-hidden="true" className="text-base leading-none">
+
</span>
{t("provider.addDifferent")}
</button>
</>
) : null}
{showProviderSetup ? (
<div className="mt-6 w-full space-y-2 text-left">
{hasUsableDefaults ? (
<p className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("provider.setupTitle")}
</p>
) : null}
{modelProviders.map((provider) => (
<ModelProviderRow
key={provider.id}
provider={{
...provider,
status: configuredIds.has(provider.id)
? "connected"
: "not_configured",
}}
onGetConfig={onGetConfig}
onSaveFields={(fields) => 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 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={onBrowseAllProviders}
>
{t("provider.browseAll")}
</Button>
) : null}
</div>
) : null}
{hasUsableDefaults ? (
<div className="mt-6 flex items-center justify-center">
<Button
type="button"
onClick={onContinue}
rightIcon={<IconArrowRight />}
>
{t("provider.useCurrent")}
</Button>
</div>
) : null}
</section>
);
}
@@ -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 (
<section className="flex w-full flex-col items-center">
<div className="flex size-14 items-center justify-center rounded-[14px] bg-muted text-muted-foreground">
<IconSparkles className="size-6" strokeWidth={1.75} />
</div>
<h2 className="mt-6 text-[22px] font-semibold tracking-tight text-foreground">
{t("tour.title")}
</h2>
<p className="mt-2 max-w-[420px] text-sm leading-6 text-muted-foreground">
{selectedSetup?.modelName
? t("tour.readyWithModel", { model: selectedSetup.modelName })
: t("tour.description")}
</p>
<div className="mt-8 flex w-full flex-col gap-2 text-left">
<div className="rounded-[14px] border border-border px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("tour.summary.defaultTitle")}
</p>
<p className="mt-1.5 text-sm font-medium text-foreground">
{selectedSetup?.modelName ?? t("tour.summary.defaultFallback")}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{t("tour.summary.defaultDescription")}
</p>
</div>
<div className="rounded-[14px] border border-border px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("tour.summary.extensionsTitle")}
</p>
<p className="mt-1.5 text-sm font-medium text-foreground">
{formatCount("extensions", extensionCount, t)}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{t("tour.summary.extensionsDescription")}
</p>
</div>
<div className="rounded-[14px] border border-border px-4 py-3">
<p className="text-[10px] font-semibold uppercase tracking-[0.08em] text-muted-foreground">
{t("tour.summary.skillsTitle")}
</p>
<p className="mt-1.5 text-sm font-medium text-foreground">
{formatCount("skills", skillCount, t)}
</p>
<p className="mt-1 text-xs leading-5 text-muted-foreground">
{t("tour.summary.skillsDescription")}
</p>
</div>
</div>
<div className="mt-8 flex items-center justify-center gap-3">
<Button type="button" variant="ghost" onClick={onBack}>
{t("tour.back")}
</Button>
<Button type="button" onClick={onFinish} rightIcon={<IconArrowRight />}>
{t("tour.finish")}
</Button>
</div>
</section>
);
}
@@ -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 = [
{
@@ -28,6 +28,13 @@ export async function saveProviderConfig(
return client.goose.GooseProvidersConfigSave({ providerId, fields });
}
export async function authenticateProviderConfig(
providerId: string,
): Promise<ProviderConfigChangeResponse> {
const client = await getClient();
return client.goose.GooseProvidersConfigAuthenticate({ providerId });
}
export async function deleteProviderConfig(
providerId: string,
): Promise<ProviderConfigChangeResponse> {
@@ -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<void> {
return invoke("authenticate_model_provider", { providerId, providerLabel });
): Promise<ProviderConfigChangeResponse> {
void providerLabel;
return authenticateProviderConfig(providerId);
}
export function onModelSetupOutput(
@@ -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([
@@ -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<ProviderFieldValue[]>;
save: (providerId: string, fields: ProviderFieldSave[]) => Promise<void>;
remove: (providerId: string) => Promise<void>;
completeNativeSetup: (providerId: string) => Promise<void>;
completeNativeSetup: (
providerId: string,
result?: ProviderConfigChangeResponse,
) => Promise<void>;
}
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 {
@@ -78,7 +78,7 @@ export function AppearanceSettings() {
<ToggleGroupItem
key={option.value}
value={option.value}
className="gap-1.5 rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm"
className="gap-1.5 rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
>
<option.icon className="h-3.5 w-3.5" />
{t(`appearance.theme.options.${option.value}`)}
@@ -107,7 +107,7 @@ export function AppearanceSettings() {
>
<span className="absolute inset-0 bg-[linear-gradient(135deg,#1a1a1a_0_50%,#ffffff_50%_100%)]" />
{accentColorPreference === "default" && (
<Check className="relative h-4 w-4 rounded-full bg-background p-0.5 text-foreground shadow-sm" />
<Check className="relative h-4 w-4 rounded-full bg-background p-0.5 text-foreground shadow-none" />
)}
</button>
{ACCENT_COLORS.map((color) => (
@@ -148,7 +148,7 @@ export function AppearanceSettings() {
<ToggleGroupItem
key={option.value}
value={option.value}
className="rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-sm"
className="rounded-md px-3 py-1.5 text-sm data-[state=on]:bg-background data-[state=on]:text-foreground data-[state=on]:shadow-none"
>
{t(`appearance.density.options.${option.value}`)}
</ToggleGroupItem>
@@ -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 (
<SettingsPage title={t("general.title")}>
@@ -61,6 +70,24 @@ export function GeneralSettings() {
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label={t("general.onboarding.label")}
description={t(
onboardingReset
? "general.onboarding.resetDescription"
: "general.onboarding.description",
)}
>
<Button
type="button"
variant="outline"
size="sm"
onClick={resetOnboarding}
>
{t("general.onboarding.reset")}
</Button>
</SettingRow>
</SettingsPage>
);
}
@@ -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<ProviderFieldValue[]>;
onSaveFields: (fields: ProviderFieldSaveInput[]) => Promise<void>;
onRemoveConfig?: () => Promise<void>;
onCompleteNativeSetup: (providerId: string) => Promise<void>;
onCompleteNativeSetup: (
providerId: string,
result?: ProviderConfigChangeResponse,
) => Promise<void>;
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
@@ -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"
+1
View File
@@ -6,6 +6,7 @@ export const TRANSLATION_NAMESPACES = [
"common",
"chat",
"home",
"onboarding",
"projects",
"settings",
"skills",
+2
View File
@@ -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"),
@@ -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"
}
}
@@ -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",
@@ -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"
}
}
@@ -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",
+3 -3
View File
@@ -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:
@@ -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, {});
+6 -4
View File
@@ -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(
+51
View File
@@ -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<ProviderConfigChangeResponse> {
const raw = await this.conn.extMethod(
"_goose/providers/config/authenticate",
params,
);
return zProviderConfigChangeResponse.parse(
raw,
) as ProviderConfigChangeResponse;
}
async GoosePreferencesRead(
params: PreferencesReadRequest,
): Promise<PreferencesReadResponse> {
@@ -366,6 +386,37 @@ export class GooseExtClient {
return zDefaultsReadResponse.parse(raw) as DefaultsReadResponse;
}
async GooseDefaultsSave(
params: DefaultsSaveRequest,
): Promise<DefaultsReadResponse> {
const raw = await this.conn.extMethod("_goose/defaults/save", params);
return zDefaultsReadResponse.parse(raw) as DefaultsReadResponse;
}
async GooseOnboardingImportScan(
params: OnboardingImportScanRequest,
): Promise<OnboardingImportScanResponse> {
const raw = await this.conn.extMethod(
"_goose/onboarding/import/scan",
params,
);
return zOnboardingImportScanResponse.parse(
raw,
) as OnboardingImportScanResponse;
}
async GooseOnboardingImportApply(
params: OnboardingImportApplyRequest,
): Promise<OnboardingImportApplyResponse> {
const raw = await this.conn.extMethod(
"_goose/onboarding/import/apply",
params,
);
return zOnboardingImportApplyResponse.parse(
raw,
) as OnboardingImportApplyResponse;
}
async GooseSessionExport(
params: ExportSessionRequest,
): Promise<ExportSessionResponse> {
+21 -1
View File
@@ -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",
+69 -2
View File
@@ -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<OnboardingImportSourceKind>;
};
/**
* Sources that onboarding knows how to discover and import.
*/
export type OnboardingImportSourceKind = 'goose_config' | 'claude_desktop';
export type OnboardingImportScanResponse = {
candidates: Array<OnboardingImportCandidate>;
};
export type OnboardingImportCandidate = {
id: string;
sourceKind: OnboardingImportSourceKind;
displayName: string;
path: string;
counts: OnboardingImportCounts;
warnings?: Array<string>;
};
export type OnboardingImportCounts = {
providers: number;
extensions: number;
sessions: number;
skills: number;
projects: number;
preferences: number;
};
/**
* Import selected onboarding candidates.
*/
export type OnboardingImportApplyRequest = {
candidateIds?: Array<string>;
enableImportedExtensions?: boolean;
};
export type OnboardingImportApplyResponse = {
imported: OnboardingImportCounts;
skipped: OnboardingImportCounts;
warnings?: Array<string>;
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;
+76
View File
@@ -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,