add provider-first onboarding (#9039)

Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
tulsi
2026-05-06 07:08:31 -07:00
committed by GitHub
parent f9d0c6416d
commit 6fc44bf0ed
51 changed files with 3589 additions and 37 deletions
+20
View File
@@ -125,6 +125,11 @@
"requestType": "ProviderConfigDeleteRequest",
"responseType": "ProviderConfigChangeResponse"
},
{
"method": "_goose/providers/config/authenticate",
"requestType": "ProviderConfigAuthenticateRequest",
"responseType": "ProviderConfigChangeResponse"
},
{
"method": "_goose/preferences/read",
"requestType": "PreferencesReadRequest",
@@ -145,6 +150,21 @@
"requestType": "DefaultsReadRequest",
"responseType": "DefaultsReadResponse"
},
{
"method": "_goose/defaults/save",
"requestType": "DefaultsSaveRequest",
"responseType": "DefaultsReadResponse"
},
{
"method": "_goose/onboarding/import/scan",
"requestType": "OnboardingImportScanRequest",
"responseType": "OnboardingImportScanResponse"
},
{
"method": "_goose/onboarding/import/apply",
"requestType": "OnboardingImportApplyRequest",
"responseType": "OnboardingImportApplyResponse"
},
{
"method": "_goose/session/export",
"requestType": "ExportSessionRequest",
+250 -1
View File
@@ -1415,6 +1415,20 @@
"x-side": "agent",
"x-method": "_goose/providers/config/delete"
},
"ProviderConfigAuthenticateRequest": {
"type": "object",
"properties": {
"providerId": {
"type": "string"
}
},
"required": [
"providerId"
],
"description": "Run a provider-owned native authentication flow and start an inventory refresh when supported.",
"x-side": "agent",
"x-method": "_goose/providers/config/authenticate"
},
"PreferencesReadRequest": {
"type": "object",
"properties": {
@@ -1521,8 +1535,191 @@
]
}
},
"x-side": "agent"
},
"DefaultsSaveRequest": {
"type": "object",
"properties": {
"providerId": {
"type": "string"
},
"modelId": {
"type": [
"string",
"null"
]
}
},
"required": [
"providerId"
],
"description": "Save Goose default provider and model configuration.",
"x-side": "agent",
"x-method": "_goose/defaults/read"
"x-method": "_goose/defaults/save"
},
"OnboardingImportScanRequest": {
"type": "object",
"properties": {
"sources": {
"type": "array",
"items": {
"$ref": "#/$defs/OnboardingImportSourceKind"
},
"description": "Empty means all supported import sources.",
"default": []
}
},
"description": "Scan for existing Goose and compatible app data that onboarding can import.",
"x-side": "agent",
"x-method": "_goose/onboarding/import/scan"
},
"OnboardingImportSourceKind": {
"type": "string",
"enum": [
"goose_config",
"claude_desktop"
],
"description": "Sources that onboarding knows how to discover and import."
},
"OnboardingImportScanResponse": {
"type": "object",
"properties": {
"candidates": {
"type": "array",
"items": {
"$ref": "#/$defs/OnboardingImportCandidate"
}
}
},
"required": [
"candidates"
],
"x-side": "agent",
"x-method": "_goose/onboarding/import/scan"
},
"OnboardingImportCandidate": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"sourceKind": {
"$ref": "#/$defs/OnboardingImportSourceKind"
},
"displayName": {
"type": "string"
},
"path": {
"type": "string"
},
"counts": {
"$ref": "#/$defs/OnboardingImportCounts"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
},
"default": []
}
},
"required": [
"id",
"sourceKind",
"displayName",
"path",
"counts"
]
},
"OnboardingImportCounts": {
"type": "object",
"properties": {
"providers": {
"type": "integer",
"minimum": 0
},
"extensions": {
"type": "integer",
"minimum": 0
},
"sessions": {
"type": "integer",
"minimum": 0
},
"skills": {
"type": "integer",
"minimum": 0
},
"projects": {
"type": "integer",
"minimum": 0
},
"preferences": {
"type": "integer",
"minimum": 0
}
},
"required": [
"providers",
"extensions",
"sessions",
"skills",
"projects",
"preferences"
]
},
"OnboardingImportApplyRequest": {
"type": "object",
"properties": {
"candidateIds": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"enableImportedExtensions": {
"type": "boolean",
"default": false
}
},
"description": "Import selected onboarding candidates.",
"x-side": "agent",
"x-method": "_goose/onboarding/import/apply"
},
"OnboardingImportApplyResponse": {
"type": "object",
"properties": {
"imported": {
"$ref": "#/$defs/OnboardingImportCounts"
},
"skipped": {
"$ref": "#/$defs/OnboardingImportCounts"
},
"warnings": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"providerDefaults": {
"anyOf": [
{
"$ref": "#/$defs/DefaultsReadResponse"
},
{
"type": "null"
}
]
}
},
"required": [
"imported",
"skipped"
],
"x-side": "agent",
"x-method": "_goose/onboarding/import/apply"
},
"ExportSessionRequest": {
"type": "object",
@@ -2533,6 +2730,15 @@
"description": "Params for _goose/providers/config/delete",
"title": "ProviderConfigDeleteRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/ProviderConfigAuthenticateRequest"
}
],
"description": "Params for _goose/providers/config/authenticate",
"title": "ProviderConfigAuthenticateRequest"
},
{
"allOf": [
{
@@ -2569,6 +2775,33 @@
"description": "Params for _goose/defaults/read",
"title": "DefaultsReadRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/DefaultsSaveRequest"
}
],
"description": "Params for _goose/defaults/save",
"title": "DefaultsSaveRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/OnboardingImportScanRequest"
}
],
"description": "Params for _goose/onboarding/import/scan",
"title": "OnboardingImportScanRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/OnboardingImportApplyRequest"
}
],
"description": "Params for _goose/onboarding/import/apply",
"title": "OnboardingImportApplyRequest"
},
{
"allOf": [
{
@@ -2957,6 +3190,22 @@
],
"title": "DefaultsReadResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/OnboardingImportScanResponse"
}
],
"title": "OnboardingImportScanResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/OnboardingImportApplyResponse"
}
],
"title": "OnboardingImportApplyResponse"
},
{
"allOf": [
{
+1
View File
@@ -72,6 +72,7 @@ mod custom_dispatch;
mod dictation;
mod dispatch;
mod extensions;
mod onboarding;
mod providers;
mod resources;
mod sessions;
+66
View File
@@ -65,6 +65,72 @@ impl GooseAcpAgent {
model_id: optional_config_string(&config, "GOOSE_MODEL")?,
})
}
pub(super) async fn on_defaults_save(
&self,
req: DefaultsSaveRequest,
) -> Result<DefaultsReadResponse, sacp::Error> {
let provider_id = req.provider_id.trim().to_string();
if provider_id.is_empty() {
return Err(sacp::Error::invalid_params().data("providerId cannot be empty"));
}
let model_id = req.model_id.and_then(|model| {
let model = model.trim().to_string();
(!model.is_empty()).then_some(model)
});
let entries = self
.provider_inventory
.entries(std::slice::from_ref(&provider_id))
.await
.internal_err_ctx("Failed to read provider inventory")?;
let Some(entry) = entries
.into_iter()
.find(|entry| entry.provider_id == provider_id)
else {
return Err(
sacp::Error::invalid_params().data(format!("Unknown provider: {provider_id}"))
);
};
if !entry.configured {
return Err(sacp::Error::invalid_params()
.data(format!("Provider is not configured: {provider_id}")));
}
if let Some(model_id) = model_id.as_deref() {
let model_exists = entry.default_model == model_id
|| entry.models.iter().any(|model| model.id == model_id);
if !model_exists {
return Err(sacp::Error::invalid_params().data(format!(
"Model '{model_id}' is not available for provider '{provider_id}'"
)));
}
}
let config = self.config()?;
config
.set_param_values(&[(
"GOOSE_PROVIDER".to_string(),
serde_json::Value::String(provider_id.clone()),
)])
.internal_err_ctx("Failed to save default provider")?;
if let Some(model_id) = model_id.as_deref() {
config
.set_param("GOOSE_MODEL", model_id)
.internal_err_ctx("Failed to save default model")?;
} else {
config
.delete("GOOSE_MODEL")
.internal_err_ctx("Failed to clear default model")?;
}
Ok(DefaultsReadResponse {
provider_id: Some(provider_id),
model_id,
})
}
}
struct PreferenceDef {
@@ -208,6 +208,14 @@ impl GooseAcpAgent {
self.on_delete_provider_config(req).await
}
#[custom_method(ProviderConfigAuthenticateRequest)]
async fn dispatch_authenticate_provider_config(
&self,
req: ProviderConfigAuthenticateRequest,
) -> Result<ProviderConfigChangeResponse, sacp::Error> {
self.on_authenticate_provider_config(req).await
}
#[custom_method(PreferencesReadRequest)]
async fn dispatch_preferences_read(
&self,
@@ -240,6 +248,30 @@ impl GooseAcpAgent {
self.on_defaults_read(req).await
}
#[custom_method(DefaultsSaveRequest)]
async fn dispatch_defaults_save(
&self,
req: DefaultsSaveRequest,
) -> Result<DefaultsReadResponse, sacp::Error> {
self.on_defaults_save(req).await
}
#[custom_method(OnboardingImportScanRequest)]
async fn dispatch_onboarding_import_scan(
&self,
req: OnboardingImportScanRequest,
) -> Result<OnboardingImportScanResponse, sacp::Error> {
self.on_onboarding_import_scan(req).await
}
#[custom_method(OnboardingImportApplyRequest)]
async fn dispatch_onboarding_import_apply(
&self,
req: OnboardingImportApplyRequest,
) -> Result<OnboardingImportApplyResponse, sacp::Error> {
self.on_onboarding_import_apply(req).await
}
#[custom_method(ExportSessionRequest)]
async fn dispatch_export_session(
&self,
+695
View File
@@ -0,0 +1,695 @@
use super::*;
use crate::config::extensions::name_to_key;
use serde::Deserialize;
use serde_yaml::Mapping;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
const GOOSE_CONFIG_PREFIX: &str = "goose_config:";
const CLAUDE_DESKTOP_PREFIX: &str = "claude_desktop:";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ClaudeDesktopConfig {
#[serde(default)]
mcp_servers: HashMap<String, ClaudeMcpServer>,
}
#[derive(Debug, Deserialize)]
struct ClaudeMcpServer {
#[serde(default)]
command: Option<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
impl GooseAcpAgent {
pub(super) async fn on_onboarding_import_scan(
&self,
req: OnboardingImportScanRequest,
) -> Result<OnboardingImportScanResponse, sacp::Error> {
let source_filter = source_filter(&req.sources);
let mut candidates = Vec::new();
if source_filter.contains(&OnboardingImportSourceKind::GooseConfig) {
for path in goose_config_candidate_paths(&self.config_dir) {
if let Some(candidate) = scan_goose_config_candidate(&path) {
candidates.push(candidate);
}
}
}
if source_filter.contains(&OnboardingImportSourceKind::ClaudeDesktop) {
for path in claude_desktop_candidate_paths() {
if let Some(candidate) = scan_claude_desktop_candidate(&path) {
candidates.push(candidate);
}
}
}
candidates.sort_by(|a, b| a.display_name.cmp(&b.display_name));
Ok(OnboardingImportScanResponse { candidates })
}
pub(super) async fn on_onboarding_import_apply(
&self,
req: OnboardingImportApplyRequest,
) -> Result<OnboardingImportApplyResponse, sacp::Error> {
let config = self.config()?;
Ok(apply_onboarding_import_candidates(
&config,
&self.config_dir,
&req,
))
}
}
fn apply_onboarding_import_candidates(
config: &Config,
target_config_dir: &Path,
req: &OnboardingImportApplyRequest,
) -> OnboardingImportApplyResponse {
let mut imported = OnboardingImportCounts::default();
let mut skipped = OnboardingImportCounts::default();
let mut warnings = Vec::new();
let mut provider_defaults = None;
for candidate_id in &req.candidate_ids {
match parse_candidate_id(candidate_id) {
Some((OnboardingImportSourceKind::GooseConfig, path)) => {
match apply_goose_config_candidate(config, target_config_dir, &path) {
Ok(result) => {
add_counts(&mut imported, &result.imported);
add_counts(&mut skipped, &result.skipped);
warnings.extend(result.warnings);
if result.provider_defaults.provider_id.is_some()
|| result.provider_defaults.model_id.is_some()
{
provider_defaults = Some(result.provider_defaults);
}
}
Err(error) => warnings.push(import_failure_warning(
OnboardingImportSourceKind::GooseConfig,
&path,
&error,
)),
}
}
Some((OnboardingImportSourceKind::ClaudeDesktop, path)) => {
match apply_claude_desktop_candidate(config, &path, req.enable_imported_extensions)
{
Ok(result) => {
add_counts(&mut imported, &result.imported);
add_counts(&mut skipped, &result.skipped);
warnings.extend(result.warnings);
}
Err(error) => warnings.push(import_failure_warning(
OnboardingImportSourceKind::ClaudeDesktop,
&path,
&error,
)),
}
}
None => warnings.push(format!("Skipped unknown import candidate: {candidate_id}")),
}
}
OnboardingImportApplyResponse {
imported,
skipped,
warnings,
provider_defaults,
}
}
#[derive(Default)]
struct ApplyResult {
imported: OnboardingImportCounts,
skipped: OnboardingImportCounts,
warnings: Vec<String>,
provider_defaults: DefaultsReadResponse,
}
fn source_filter(sources: &[OnboardingImportSourceKind]) -> HashSet<OnboardingImportSourceKind> {
if sources.is_empty() {
return [
OnboardingImportSourceKind::GooseConfig,
OnboardingImportSourceKind::ClaudeDesktop,
]
.into_iter()
.collect();
}
sources.iter().copied().collect()
}
fn add_counts(target: &mut OnboardingImportCounts, source: &OnboardingImportCounts) {
target.providers += source.providers;
target.extensions += source.extensions;
target.sessions += source.sessions;
target.skills += source.skills;
target.projects += source.projects;
target.preferences += source.preferences;
}
fn import_failure_warning(
source_kind: OnboardingImportSourceKind,
path: &Path,
error: &anyhow::Error,
) -> String {
let source_name = match source_kind {
OnboardingImportSourceKind::GooseConfig => "Goose configuration",
OnboardingImportSourceKind::ClaudeDesktop => "Claude Desktop tools",
};
format!(
"Skipped {source_name} import at {}: {error}",
path.display()
)
}
fn goose_config_candidate_paths(config_dir: &Path) -> Vec<PathBuf> {
let mut paths = vec![config_dir.join(CONFIG_YAML_NAME)];
if let Some(home) = dirs::home_dir() {
paths.push(home.join(".config").join("goose").join(CONFIG_YAML_NAME));
}
dedupe_paths(paths)
}
fn claude_desktop_candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("Claude").join("claude_desktop_config.json"));
}
if let Some(home) = dirs::home_dir() {
paths.push(
home.join("Library")
.join("Application Support")
.join("Claude")
.join("claude_desktop_config.json"),
);
paths.push(
home.join("AppData")
.join("Roaming")
.join("Claude")
.join("claude_desktop_config.json"),
);
}
dedupe_paths(paths)
}
fn dedupe_paths(paths: Vec<PathBuf>) -> Vec<PathBuf> {
let mut seen = HashSet::new();
paths
.into_iter()
.filter(|path| seen.insert(path.clone()))
.collect()
}
fn scan_goose_config_candidate(path: &Path) -> Option<OnboardingImportCandidate> {
if !path.exists() {
return None;
}
let mapping = read_yaml_mapping(path).ok()?;
let mut counts = OnboardingImportCounts::default();
let mut warnings = Vec::new();
if mapping_contains_string(&mapping, "GOOSE_PROVIDER")
|| mapping_contains_string(&mapping, "GOOSE_MODEL")
{
counts.providers = 1;
}
counts.extensions = extension_count(&mapping);
counts.skills = path
.parent()
.map(|dir| count_skill_dirs(&dir.join("skills")))
.unwrap_or_default();
if counts.sessions > 0 {
warnings.push("Sessions are already shared through Goose's data store.".to_string());
}
Some(OnboardingImportCandidate {
id: candidate_id(GOOSE_CONFIG_PREFIX, path),
source_kind: OnboardingImportSourceKind::GooseConfig,
display_name: "Existing Goose configuration".to_string(),
path: path.to_string_lossy().to_string(),
counts,
warnings,
})
}
fn scan_claude_desktop_candidate(path: &Path) -> Option<OnboardingImportCandidate> {
if !path.exists() {
return None;
}
let (servers, warnings) = read_claude_servers(path).ok()?;
if servers.is_empty() && warnings.is_empty() {
return None;
}
Some(OnboardingImportCandidate {
id: candidate_id(CLAUDE_DESKTOP_PREFIX, path),
source_kind: OnboardingImportSourceKind::ClaudeDesktop,
display_name: "Claude Desktop tools".to_string(),
path: path.to_string_lossy().to_string(),
counts: OnboardingImportCounts {
extensions: servers.len() as u32,
..Default::default()
},
warnings,
})
}
fn candidate_id(prefix: &str, path: &Path) -> String {
format!("{prefix}{}", path.to_string_lossy())
}
fn parse_candidate_id(id: &str) -> Option<(OnboardingImportSourceKind, PathBuf)> {
if let Some(path) = id.strip_prefix(GOOSE_CONFIG_PREFIX) {
return Some((OnboardingImportSourceKind::GooseConfig, PathBuf::from(path)));
}
if let Some(path) = id.strip_prefix(CLAUDE_DESKTOP_PREFIX) {
return Some((
OnboardingImportSourceKind::ClaudeDesktop,
PathBuf::from(path),
));
}
None
}
fn read_yaml_mapping(path: &Path) -> anyhow::Result<Mapping> {
let content = fs::read_to_string(path)?;
let value: serde_yaml::Value = serde_yaml::from_str(&content)?;
Ok(value.as_mapping().cloned().unwrap_or_default())
}
fn mapping_contains_string(mapping: &Mapping, key: &str) -> bool {
mapping
.get(serde_yaml::Value::String(key.to_string()))
.and_then(|value| value.as_str())
.is_some_and(|value| !value.trim().is_empty())
}
fn extension_count(mapping: &Mapping) -> u32 {
mapping
.get(serde_yaml::Value::String("extensions".to_string()))
.and_then(|value| value.as_mapping())
.map(|extensions| extensions.len() as u32)
.unwrap_or_default()
}
fn count_skill_dirs(path: &Path) -> u32 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
entries
.flatten()
.map(|entry| entry.path())
.filter(|path| path.is_dir() && path.join("SKILL.md").exists())
.count() as u32
}
fn apply_goose_config_candidate(
target_config: &Config,
target_config_dir: &Path,
source_path: &Path,
) -> anyhow::Result<ApplyResult> {
let source = read_yaml_mapping(source_path)?;
let mut result = ApplyResult::default();
let provider = yaml_string(&source, "GOOSE_PROVIDER");
let model = yaml_string(&source, "GOOSE_MODEL");
if provider.is_some() || model.is_some() {
let mut updates = Vec::new();
if let Some(provider) = provider.clone() {
updates.push((
"GOOSE_PROVIDER".to_string(),
serde_json::Value::String(provider),
));
}
if let Some(model) = model.clone() {
updates.push(("GOOSE_MODEL".to_string(), serde_json::Value::String(model)));
}
target_config.set_param_values(&updates)?;
result.provider_defaults = DefaultsReadResponse {
provider_id: provider,
model_id: model,
};
result.imported.providers = 1;
}
let extension_result = import_goose_config_extensions(target_config, &source)?;
result.imported.extensions += extension_result.imported;
result.skipped.extensions += extension_result.skipped;
if let Some(source_dir) = source_path.parent() {
let secrets_result = import_file_secrets(target_config, &source_dir.join("secrets.yaml"))?;
if secrets_result > 0 && result.imported.providers == 0 {
result.imported.providers = 1;
}
let skills_result = import_skill_dirs(
&source_dir.join("skills"),
&target_config_dir.join("skills"),
)?;
result.imported.skills += skills_result.imported;
result.skipped.skills += skills_result.skipped;
}
result
.warnings
.push("Session history already lives in the Goose data store when available.".to_string());
Ok(result)
}
fn yaml_string(mapping: &Mapping, key: &str) -> Option<String> {
mapping
.get(serde_yaml::Value::String(key.to_string()))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
#[derive(Default)]
struct ImportPairCount {
imported: u32,
skipped: u32,
}
fn import_goose_config_extensions(
target_config: &Config,
source: &Mapping,
) -> anyhow::Result<ImportPairCount> {
let Some(source_extensions) = source
.get(serde_yaml::Value::String("extensions".to_string()))
.and_then(|value| value.as_mapping())
else {
return Ok(ImportPairCount::default());
};
let mut target_extensions = load_target_extensions(target_config)?;
let mut counts = ImportPairCount::default();
for (key, value) in source_extensions {
if target_extensions.contains_key(key) {
counts.skipped += 1;
continue;
}
target_extensions.insert(key.clone(), value.clone());
counts.imported += 1;
}
if counts.imported > 0 {
target_config.set_param("extensions", target_extensions)?;
}
Ok(counts)
}
fn load_target_extensions(target_config: &Config) -> anyhow::Result<Mapping> {
match target_config.get_param::<Mapping>("extensions") {
Ok(extensions) => Ok(extensions),
Err(crate::config::ConfigError::NotFound(_)) => Ok(Mapping::new()),
Err(error) => Err(error.into()),
}
}
fn import_file_secrets(target_config: &Config, source_path: &Path) -> anyhow::Result<u32> {
if !source_path.exists() {
return Ok(0);
}
let mapping = read_yaml_mapping(source_path)?;
let mut updates = Vec::new();
for (key, value) in mapping {
let Some(key) = key.as_str() else {
continue;
};
updates.push((key.to_string(), serde_json::to_value(value)?));
}
target_config.set_secret_values(&updates)?;
Ok(updates.len() as u32)
}
fn import_skill_dirs(source_root: &Path, target_root: &Path) -> anyhow::Result<ImportPairCount> {
let Ok(entries) = fs::read_dir(source_root) else {
return Ok(ImportPairCount::default());
};
let mut counts = ImportPairCount::default();
fs::create_dir_all(target_root)?;
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() || file_type.is_symlink() {
continue;
}
let source = entry.path();
if !source.join("SKILL.md").exists() {
continue;
}
let Some(name) = source.file_name() else {
continue;
};
let target = target_root.join(name);
if target.exists() {
counts.skipped += 1;
continue;
}
copy_dir_recursively(&source, &target)?;
counts.imported += 1;
}
Ok(counts)
}
fn copy_dir_recursively(source: &Path, target: &Path) -> anyhow::Result<()> {
fs::create_dir_all(target)?;
for entry in fs::read_dir(source)? {
let entry = entry?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = fs::symlink_metadata(&source_path)?.file_type();
if file_type.is_symlink() {
continue;
}
if file_type.is_dir() {
copy_dir_recursively(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)?;
}
}
Ok(())
}
fn apply_claude_desktop_candidate(
target_config: &Config,
source_path: &Path,
enable_imported_extensions: bool,
) -> anyhow::Result<ApplyResult> {
let (servers, mut warnings) = read_claude_servers(source_path)?;
let mut target_extensions = load_target_extensions(target_config)?;
let mut result = ApplyResult::default();
for (name, server) in servers {
let command = match server.command {
Some(command) if !command.trim().is_empty() => command,
_ => {
result.skipped.extensions += 1;
warnings.push(format!(
"Skipped Claude MCP server '{name}' because it has no command."
));
continue;
}
};
let key = serde_yaml::Value::String(name_to_key(&name));
if target_extensions.contains_key(&key) {
result.skipped.extensions += 1;
continue;
}
let entry = crate::config::extensions::ExtensionEntry {
enabled: enable_imported_extensions,
config: ExtensionConfig::Stdio {
name,
description: "Imported from Claude Desktop".to_string(),
cmd: command,
args: server.args,
envs: Envs::new(server.env),
env_keys: Vec::new(),
timeout: Some(crate::config::DEFAULT_EXTENSION_TIMEOUT),
bundled: None,
available_tools: Vec::new(),
},
};
target_extensions.insert(key, serde_yaml::to_value(entry)?);
result.imported.extensions += 1;
}
if result.imported.extensions > 0 {
target_config.set_param("extensions", target_extensions)?;
}
result.warnings.extend(warnings);
if !enable_imported_extensions && result.imported.extensions > 0 {
result
.warnings
.push("Imported Claude Desktop MCP servers are disabled by default.".to_string());
}
Ok(result)
}
fn read_claude_servers(
path: &Path,
) -> anyhow::Result<(HashMap<String, ClaudeMcpServer>, Vec<String>)> {
let content = fs::read_to_string(path)?;
let config: ClaudeDesktopConfig = serde_json::from_str(&content)?;
let mut servers = HashMap::new();
let mut warnings = Vec::new();
for (name, server) in config.mcp_servers {
if server
.command
.as_deref()
.is_some_and(|command| !command.trim().is_empty())
{
servers.insert(name, server);
} else {
warnings.push(format!("Claude MCP server '{name}' has no command."));
}
}
Ok((servers, warnings))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn scan_claude_desktop_counts_valid_servers() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("claude_desktop_config.json");
fs::write(
&path,
r#"{
"mcpServers": {
"github": { "command": "npx", "args": ["github-mcp"] },
"broken": { "args": ["missing-command"] }
}
}"#,
)
.unwrap();
let candidate = scan_claude_desktop_candidate(&path).unwrap();
assert_eq!(candidate.counts.extensions, 1);
assert_eq!(candidate.warnings.len(), 1);
}
#[test]
fn apply_onboarding_imports_continues_after_candidate_failure() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let missing_goose_config = source.path().join("missing-config.yaml");
let claude_config = source.path().join("claude_desktop_config.json");
fs::write(
&claude_config,
r#"{
"mcpServers": {
"github": { "command": "npx", "args": ["github-mcp"] }
}
}"#,
)
.unwrap();
let target_config = Config::new_with_file_secrets(
target.path().join(CONFIG_YAML_NAME),
target.path().join("secrets.yaml"),
)
.unwrap();
let req = OnboardingImportApplyRequest {
candidate_ids: vec![
candidate_id(GOOSE_CONFIG_PREFIX, &missing_goose_config),
candidate_id(CLAUDE_DESKTOP_PREFIX, &claude_config),
],
enable_imported_extensions: false,
};
let response = apply_onboarding_import_candidates(&target_config, target.path(), &req);
assert_eq!(response.imported.extensions, 1);
assert!(response
.warnings
.iter()
.any(|warning| warning.starts_with("Skipped Goose configuration import at ")));
let extensions = target_config.get_param::<Mapping>("extensions").unwrap();
assert!(extensions.contains_key(serde_yaml::Value::String(name_to_key("github"))));
}
#[test]
fn apply_goose_config_imports_defaults_extensions_and_skills() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let source_config = source.path().join(CONFIG_YAML_NAME);
fs::write(
&source_config,
r#"
GOOSE_PROVIDER: openai
GOOSE_MODEL: gpt-5.1
extensions:
github:
enabled: true
type: stdio
name: github
description: GitHub
cmd: npx
args: ["github-mcp"]
"#,
)
.unwrap();
let skill_dir = source.path().join("skills").join("reviewer");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# Reviewer").unwrap();
let target_config = Config::new_with_file_secrets(
target.path().join(CONFIG_YAML_NAME),
target.path().join("secrets.yaml"),
)
.unwrap();
let result =
apply_goose_config_candidate(&target_config, target.path(), &source_config).unwrap();
assert_eq!(result.imported.providers, 1);
assert_eq!(result.imported.extensions, 1);
assert_eq!(result.imported.skills, 1);
assert_eq!(
target_config.get_param::<String>("GOOSE_PROVIDER").unwrap(),
"openai"
);
assert!(target.path().join("skills").join("reviewer").exists());
}
#[cfg(unix)]
#[test]
fn import_skill_dirs_skips_symlink_cycles() {
let source = TempDir::new().unwrap();
let target = TempDir::new().unwrap();
let skill_dir = source.path().join("reviewer");
fs::create_dir_all(&skill_dir).unwrap();
fs::write(skill_dir.join("SKILL.md"), "# Reviewer").unwrap();
std::os::unix::fs::symlink(&skill_dir, skill_dir.join("loop")).unwrap();
let result = import_skill_dirs(source.path(), target.path()).unwrap();
assert_eq!(result.imported, 1);
assert!(target.path().join("reviewer").join("SKILL.md").exists());
assert!(!target.path().join("reviewer").join("loop").exists());
}
}
+31
View File
@@ -894,4 +894,35 @@ impl GooseAcpAgent {
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
Ok(ProviderConfigChangeResponse { status, refresh })
}
pub(super) async fn on_authenticate_provider_config(
&self,
req: ProviderConfigAuthenticateRequest,
) -> Result<ProviderConfigChangeResponse, sacp::Error> {
let entry = crate::providers::get_from_registry(&req.provider_id)
.await
.invalid_params_err_ctx("Unknown provider")?;
let metadata = entry.metadata().clone();
if !metadata.config_keys.iter().any(|key| key.oauth_flow) {
return Err(sacp::Error::invalid_params().data(format!(
"Provider does not support native authentication: {}",
req.provider_id
)));
}
let provider = entry
.create_with_default_model(Vec::new())
.await
.internal_err_ctx("Failed to initialize provider")?;
provider
.configure_oauth()
.await
.internal_err_ctx("Failed to authenticate provider")?;
Config::global().invalidate_secrets_cache();
let provider_ids = [req.provider_id.clone()];
let status = Self::provider_config_status(req.provider_id.clone()).await;
let refresh = self.start_provider_inventory_refresh(&provider_ids).await?;
Ok(ProviderConfigChangeResponse { status, refresh })
}
}
@@ -943,6 +943,10 @@ impl ProviderDef for ChatGptCodexProvider {
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(Self::from_env(model))
}
fn inventory_configured() -> bool {
TokenCache::new().load().is_some()
}
}
#[async_trait]
@@ -1050,6 +1054,29 @@ mod tests {
.unwrap_or_default()
}
#[test]
#[serial_test::serial]
fn inventory_configured_uses_oauth_token_cache() {
let root = tempfile::tempdir().unwrap();
let root_path = root.path().to_string_lossy().to_string();
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(root_path.as_str()))]);
TokenCache::new().clear();
assert!(!ChatGptCodexProvider::inventory_configured());
TokenCache::new()
.save(&TokenData {
access_token: "access".to_string(),
refresh_token: "refresh".to_string(),
id_token: None,
expires_at: Utc::now() + chrono::Duration::hours(1),
account_id: Some("account".to_string()),
})
.unwrap();
assert!(ChatGptCodexProvider::inventory_configured());
}
#[test_case(
vec![
Message::user().with_text("user text"),
@@ -192,6 +192,17 @@ fn acp_catalog_and_custom_provider_methods_use_core_provider_store() {
"provider config read should not expose raw secret values"
);
let non_oauth_auth = send_custom(
conn.cx(),
"_goose/providers/config/authenticate",
serde_json::json!({ "providerId": "xai" }),
)
.await;
assert!(
non_oauth_auth.is_err(),
"native auth should reject providers without an OAuth flow"
);
Config::global().invalidate_secrets_cache();
assert!(Config::global()
.get_secret::<String>("CUSTOM_STARK_ACP_PROVIDER_API_KEY")