add provider-first onboarding (#9039)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user