mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
add provider-first onboarding (#9039)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -72,6 +72,7 @@ mod custom_dispatch;
|
||||
mod dictation;
|
||||
mod dispatch;
|
||||
mod extensions;
|
||||
mod onboarding;
|
||||
mod providers;
|
||||
mod resources;
|
||||
mod sessions;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -6,6 +6,7 @@ export const TRANSLATION_NAMESPACES = [
|
||||
"common",
|
||||
"chat",
|
||||
"home",
|
||||
"onboarding",
|
||||
"projects",
|
||||
"settings",
|
||||
"skills",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user