diff --git a/crates/ai/src/acp/bridge_init.rs b/crates/ai/src/acp/bridge_init.rs index 921ce4f43..730c90e03 100644 --- a/crates/ai/src/acp/bridge_init.rs +++ b/crates/ai/src/acp/bridge_init.rs @@ -104,6 +104,8 @@ pub(super) async fn initialize_worker( SessionBootstrapContext { auth_methods, supports_session_resume, + default_mode: config.default_mode.clone(), + default_model: config.default_model.clone(), map_config_options, child: &mut child, io_handle: &io_handle, @@ -143,6 +145,8 @@ where { auth_methods: Vec, supports_session_resume: bool, + default_mode: Option, + default_model: Option, map_config_options: F, child: &'a mut Child, io_handle: &'a tokio::task::JoinHandle<()>, @@ -340,6 +344,14 @@ async fn bootstrap_session( match load_result { Ok(Ok(load_response)) => { + apply_session_defaults( + connection.clone(), + acp::SessionId::new(existing_session_id.clone()), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + load_response.config_options.as_ref(), + ) + .await; log::info!("ACP session loaded: {}", existing_session_id); client.set_session_id(existing_session_id.clone()).await; return Ok(SessionBootstrap { @@ -375,6 +387,14 @@ async fn bootstrap_session( match resume_result { Ok(Ok(resume_response)) => { + apply_session_defaults( + connection.clone(), + acp::SessionId::new(existing_session_id.clone()), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + resume_response.config_options.as_ref(), + ) + .await; log::info!("ACP session resumed: {}", existing_session_id); client.set_session_id(existing_session_id.clone()).await; return Ok(SessionBootstrap { @@ -478,6 +498,14 @@ async fn bootstrap_session( }; log::info!("ACP session created: {}", session.session_id); + apply_session_defaults( + connection.clone(), + session.session_id.clone(), + ctx.default_mode.as_deref(), + ctx.default_model.as_deref(), + session.config_options.as_ref(), + ) + .await; client.set_session_id(session.session_id.to_string()).await; Ok(SessionBootstrap { @@ -491,7 +519,7 @@ async fn create_session( connection: Arc, cwd: PathBuf, ) -> Result, tokio::time::error::Elapsed> { - let session_request = acp::NewSessionRequest::new(cwd); + let session_request = new_session_request(cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.new_session(session_request), @@ -504,7 +532,7 @@ async fn load_session( cwd: PathBuf, existing_session_id: String, ) -> Result, tokio::time::error::Elapsed> { - let request = acp::LoadSessionRequest::new(existing_session_id, cwd); + let request = load_session_request(existing_session_id, cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.load_session(request), @@ -517,7 +545,7 @@ async fn resume_session( cwd: PathBuf, existing_session_id: String, ) -> Result, tokio::time::error::Elapsed> { - let request = acp::ResumeSessionRequest::new(existing_session_id, cwd); + let request = resume_session_request(existing_session_id, cwd); tokio::time::timeout( std::time::Duration::from_secs(30), connection.resume_session(request), @@ -525,6 +553,74 @@ async fn resume_session( .await } +fn new_session_request(cwd: PathBuf) -> acp::NewSessionRequest { + acp::NewSessionRequest::new(cwd) +} + +fn load_session_request(existing_session_id: String, cwd: PathBuf) -> acp::LoadSessionRequest { + acp::LoadSessionRequest::new(existing_session_id, cwd) +} + +fn resume_session_request(existing_session_id: String, cwd: PathBuf) -> acp::ResumeSessionRequest { + acp::ResumeSessionRequest::new(existing_session_id, cwd) +} + +async fn apply_session_defaults( + connection: Arc, + session_id: acp::SessionId, + default_mode: Option<&str>, + default_model: Option<&str>, + config_options: Option<&Vec>, +) { + if let Some(mode_id) = default_mode.filter(|mode| !mode.trim().is_empty()) + && let Err(error) = connection + .set_session_mode(acp::SetSessionModeRequest::new( + session_id.clone(), + mode_id.to_string(), + )) + .await + { + log::warn!("Failed to apply ACP default mode '{}': {}", mode_id, error); + } + + let Some(model_id) = default_model.filter(|model| !model.trim().is_empty()) else { + return; + }; + let Some(config_id) = model_config_option_id(config_options) else { + log::debug!( + "ACP default model '{}' configured, but the agent did not expose a model config option", + model_id + ); + return; + }; + + if let Err(error) = connection + .set_session_config_option(acp::SetSessionConfigOptionRequest::new( + session_id, + config_id, + model_id.to_string(), + )) + .await + { + log::warn!( + "Failed to apply ACP default model '{}': {}", + model_id, + error + ); + } +} + +fn model_config_option_id( + config_options: Option<&Vec>, +) -> Option { + config_options? + .iter() + .find(|option| { + option.id.to_string() == "model" || option.category.as_deref() == Some("model") + }) + .map(|option| option.id.to_string()) +} + fn map_mode_state(modes: acp::SessionModeState) -> SessionModeState { SessionModeState { current_mode_id: Some(modes.current_mode_id.to_string()), @@ -570,3 +666,34 @@ fn emit_initial_session_state( log::warn!("Failed to emit initial session config options: {}", e); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_session_request_sets_cwd() { + let request = new_session_request(PathBuf::from("/repo")); + + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } + + #[test] + fn load_session_request_sets_session_and_cwd() { + let request = load_session_request("session-1".to_string(), PathBuf::from("/repo")); + + assert_eq!(request.session_id, acp::SessionId::new("session-1")); + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } + + #[test] + fn resume_session_request_sets_session_and_cwd() { + let request = resume_session_request("session-1".to_string(), PathBuf::from("/repo")); + + assert_eq!(request.session_id, acp::SessionId::new("session-1")); + assert_eq!(request.cwd, PathBuf::from("/repo")); + assert!(request.mcp_servers.is_empty()); + } +} diff --git a/crates/ai/src/acp/config.rs b/crates/ai/src/acp/config.rs index 44cab9288..f66908c49 100644 --- a/crates/ai/src/acp/config.rs +++ b/crates/ai/src/acp/config.rs @@ -94,6 +94,14 @@ impl AgentRegistry { continue; } + if let Some(path) = config.binary_path.as_ref().map(PathBuf::from) + && path.is_file() + { + config.installed = true; + config.binary_path = Some(path.to_string_lossy().to_string()); + continue; + } + if let Some(path) = find_binary(&config.binary_name) { config.installed = true; config.binary_path = Some(path.to_string_lossy().to_string()); diff --git a/crates/ai/src/acp/types.rs b/crates/ai/src/acp/types.rs index c6e9af22a..33c40c430 100644 --- a/crates/ai/src/acp/types.rs +++ b/crates/ai/src/acp/types.rs @@ -204,6 +204,8 @@ pub struct AgentConfig { pub binary_path: Option, pub args: Vec, pub env_vars: HashMap, + pub default_mode: Option, + pub default_model: Option, pub icon: Option, pub description: Option, pub installed: bool, @@ -224,6 +226,8 @@ impl AgentConfig { binary_path: None, args: Vec::new(), env_vars: HashMap::new(), + default_mode: None, + default_model: None, icon: None, description: None, installed: false, diff --git a/crates/tooling/src/installer.rs b/crates/tooling/src/installer.rs index 84cdd6073..2f2dd323f 100644 --- a/crates/tooling/src/installer.rs +++ b/crates/tooling/src/installer.rs @@ -104,7 +104,7 @@ impl ToolInstaller { } fn known_node_companion_packages(package: &str) -> &'static [&'static str] { - match package { + match Self::node_package_name(package).as_str() { // typescript-language-server declares TypeScript as a peer dependency // and exits during LSP initialize if it cannot resolve it locally. "typescript-language-server" => &["typescript"], @@ -129,6 +129,35 @@ impl ToolInstaller { packages } + fn node_package_name(package_spec: &str) -> String { + let spec = package_spec.trim(); + if let Some(scoped) = spec.strip_prefix('@') { + let mut segments = scoped.splitn(3, '/'); + let Some(scope) = segments.next() else { + return spec.to_string(); + }; + let Some(name_and_version) = segments.next() else { + return spec.to_string(); + }; + let name = name_and_version + .rsplit_once('@') + .map(|(name, _)| name) + .unwrap_or(name_and_version); + if name.is_empty() { + spec.to_string() + } else { + format!("@{scope}/{name}") + } + } else { + spec + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(spec) + .to_string() + } + } + fn node_companion_packages_to_validate( package: &str, companion_packages: &[String], @@ -219,7 +248,9 @@ impl ToolInstaller { package: &str, command_name: &str, ) -> Option { - let package_root = package_dir.join("node_modules").join(package); + let package_root = package_dir + .join("node_modules") + .join(Self::node_package_name(package)); Self::resolve_node_package_entrypoint_from_root(&package_root, command_name, true) .filter(|path| path.exists()) } @@ -1304,6 +1335,22 @@ mod tests { assert!(ready.is_ok()); } + #[test] + fn resolves_node_package_name_from_versioned_specs() { + assert_eq!( + ToolInstaller::node_package_name("typescript-language-server@4.4.1"), + "typescript-language-server" + ); + assert_eq!( + ToolInstaller::node_package_name("@vtsls/language-server@0.2.9"), + "@vtsls/language-server" + ); + assert_eq!( + ToolInstaller::node_package_name("@vtsls/language-server"), + "@vtsls/language-server" + ); + } + #[test] fn resolves_node_bin_shim_when_present() { let temp = tempfile::tempdir().unwrap(); @@ -1358,6 +1405,41 @@ mod tests { assert_eq!(resolved.as_deref(), Some(entrypoint.as_path())); } + #[test] + fn resolves_versioned_node_package_entrypoint() { + let temp = tempfile::tempdir().unwrap(); + let package_dir = temp + .path() + .join("npm") + .join("@agentclientprotocol") + .join("claude-agent-acp@0.33.1"); + let package_root = package_dir + .join("node_modules") + .join("@agentclientprotocol") + .join("claude-agent-acp"); + let entrypoint = package_root.join("bin").join("claude-agent-acp.js"); + fs::create_dir_all(entrypoint.parent().unwrap()).unwrap(); + fs::write( + package_root.join("package.json"), + r#"{ + "name": "@agentclientprotocol/claude-agent-acp", + "bin": { + "claude-agent-acp": "./bin/claude-agent-acp.js" + } +}"#, + ) + .unwrap(); + fs::write(&entrypoint, "").unwrap(); + + let resolved = ToolInstaller::resolve_node_package_binary( + &package_dir, + "@agentclientprotocol/claude-agent-acp@0.33.1", + "claude-agent-acp", + ); + + assert_eq!(resolved.as_deref(), Some(entrypoint.as_path())); + } + #[test] fn writes_ruby_wrapper_for_managed_gem_executable() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/tooling/src/registry.rs b/crates/tooling/src/registry.rs index 8c79d3346..ab0638550 100644 --- a/crates/tooling/src/registry.rs +++ b/crates/tooling/src/registry.rs @@ -296,8 +296,7 @@ mod tests { #[test] fn resolves_url_placeholders() { - let template = - "https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}"; + let template = "https://example.com/${os}/${arch}/${platformArch}/${targetOs}/${targetArch}.${archiveExt}?v=${version}"; let resolved = ToolRegistry::resolve_url_template(template); assert!(!resolved.contains("${")); diff --git a/src-tauri/src/commands/ai/acp.rs b/src-tauri/src/commands/ai/acp.rs index 7f0021d9b..f5b46d75f 100644 --- a/src-tauri/src/commands/ai/acp.rs +++ b/src-tauri/src/commands/ai/acp.rs @@ -11,11 +11,16 @@ use std::{ time::{Duration, Instant}, }; use tauri::{Manager, State}; +use tauri_plugin_store::StoreExt; use tokio::sync::Mutex; pub type AcpBridgeState = Arc>; const EXTENSIONS_CDN_BASE_URL: &str = "https://athas.dev/extensions"; +const ACP_REGISTRY_URL: &str = + "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"; const AGENT_CATALOG_CACHE_SECONDS: u64 = 300; +const ACP_REGISTRY_USER_AGENT: &str = concat!("Athas/", env!("CARGO_PKG_VERSION")); +const EXCLUDED_ACP_REGISTRY_AGENT_IDS: &[&str] = &["agoragentic-acp"]; #[derive(Deserialize)] pub struct PermissionResponseArgs { @@ -30,15 +35,17 @@ pub struct PermissionResponseArgs { #[tauri::command] pub async fn get_available_agents( + app_handle: AppHandle, bridge: State<'_, AcpBridgeState>, ) -> Result, String> { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; Ok(bridge.detect_agents()) } #[tauri::command] pub async fn start_acp_agent( + app_handle: AppHandle, bridge: State<'_, AcpBridgeState>, agent_id: String, workspace_path: Option, @@ -46,7 +53,7 @@ pub async fn start_acp_agent( ) -> Result { let bridge = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; bridge.detect_agents(); bridge.clone() }; @@ -64,7 +71,7 @@ pub async fn install_acp_agent( ) -> Result { let agent = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; let agents = bridge.detect_agents(); agents .into_iter() @@ -97,7 +104,7 @@ pub async fn uninstall_acp_agent( ) -> Result { let agent = { let mut bridge = bridge.lock().await; - refresh_registered_agents(&mut bridge).await; + refresh_registered_agents(&app_handle, &mut bridge).await; bridge.invalidate_agent_detection_cache(); let agents = bridge.detect_agents(); agents @@ -163,12 +170,81 @@ struct MarketplaceExtensionManifest { agents: Vec, } +#[derive(Deserialize)] +struct AcpRegistryIndex { + #[serde(default)] + agents: Vec, +} + +#[derive(Deserialize)] +struct AcpRegistryAgent { + id: String, + name: String, + description: String, + icon: Option, + distribution: AcpRegistryDistribution, +} + +#[derive(Deserialize)] +struct AcpRegistryDistribution { + binary: Option>, + npx: Option, + uvx: Option, +} + +#[derive(Deserialize)] +struct AcpRegistryBinaryTarget { + archive: String, + cmd: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +#[derive(Deserialize)] +struct AcpRegistryPackageTarget { + package: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +enum AcpAgentServerSetting { + Custom { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, + #[serde(default, alias = "defaultMode")] + default_mode: Option, + #[serde(default, alias = "defaultModel")] + default_model: Option, + }, + Registry { + #[serde(default)] + env: HashMap, + #[serde(default, alias = "defaultMode")] + default_mode: Option, + #[serde(default, alias = "defaultModel")] + default_model: Option, + }, +} + fn extensions_manifest_url() -> String { let base_url = std::env::var("ATHAS_EXTENSIONS_CDN_URL") .unwrap_or_else(|_| EXTENSIONS_CDN_BASE_URL.to_string()); format!("{}/manifests.json", base_url.trim_end_matches('/')) } +fn acp_registry_url() -> String { + std::env::var("ATHAS_ACP_REGISTRY_URL").unwrap_or_else(|_| ACP_REGISTRY_URL.to_string()) +} + fn current_platform_arch() -> Option<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Some("darwin-arm64"), @@ -181,6 +257,110 @@ fn current_platform_arch() -> Option<&'static str> { } } +fn current_acp_registry_platform() -> Option<&'static str> { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => Some("darwin-aarch64"), + ("macos", "x86_64") => Some("darwin-x86_64"), + ("linux", "aarch64") => Some("linux-aarch64"), + ("linux", "x86_64") => Some("linux-x86_64"), + ("windows", "aarch64") => Some("windows-aarch64"), + ("windows", "x86_64") => Some("windows-x86_64"), + _ => None, + } +} + +fn registry_command_name(cmd: &str, fallback: &str) -> String { + Path::new(cmd) + .file_name() + .and_then(|name| name.to_str()) + .map(|name| { + if cfg!(windows) { + name.strip_suffix(".exe").unwrap_or(name).to_string() + } else { + name.to_string() + } + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn npm_package_name(package_spec: &str) -> String { + let spec = package_spec.trim(); + if let Some(scoped) = spec.strip_prefix('@') { + let mut segments = scoped.splitn(3, '/'); + let Some(scope) = segments.next() else { + return spec.to_string(); + }; + let Some(name_and_version) = segments.next() else { + return spec.to_string(); + }; + let name = name_and_version + .rsplit_once('@') + .map(|(name, _)| name) + .unwrap_or(name_and_version); + if name.is_empty() { + spec.to_string() + } else { + format!("@{scope}/{name}") + } + } else { + spec + .rsplit_once('@') + .map(|(name, _)| name) + .filter(|name| !name.is_empty()) + .unwrap_or(spec) + .to_string() + } +} + +fn package_command_name(package_name: &str, fallback: &str) -> String { + package_name + .rsplit('/') + .next() + .filter(|name| !name.is_empty()) + .unwrap_or(fallback) + .to_string() +} + +fn npx_command_name(package_name: &str, fallback: &str) -> String { + match package_name { + "@google/gemini-cli" => "gemini".to_string(), + "@qwen-code/qwen-code" => "qwen".to_string(), + "@tencent-ai/codebuddy-code" => "codebuddy".to_string(), + "dirac-cli" => "dirac".to_string(), + _ => package_command_name(package_name, fallback), + } +} + +fn python_package_spec_from_uvx(package_spec: &str) -> String { + let spec = package_spec.trim(); + let package = if spec.contains("==") { + spec.to_string() + } else { + spec + .rsplit_once('@') + .map(|(package, version)| format!("{package}=={version}")) + .unwrap_or_else(|| spec.to_string()) + }; + + package + .strip_prefix("fast-agent-acp==") + .map(|version| format!("fast-agent-mcp=={version}")) + .unwrap_or(package) +} + +fn python_command_name(package_spec: &str, fallback: &str) -> String { + let package = package_spec + .split_once("==") + .map(|(package, _)| package) + .unwrap_or(package_spec) + .trim(); + if package == "fast-agent-mcp" { + return "fast-agent-acp".to_string(); + } + package_command_name(package, fallback) +} + fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { let mut agent = AgentConfig { id: contribution.id, @@ -189,6 +369,8 @@ fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { binary_path: None, args: contribution.args, env_vars: contribution.env_vars, + default_mode: None, + default_model: None, icon: contribution.icon, description: contribution.description, installed: false, @@ -217,7 +399,306 @@ fn to_agent_config(contribution: MarketplaceAgentContribution) -> AgentConfig { agent } -async fn load_marketplace_agents() -> Result, String> { +fn acp_registry_agent_to_config(agent: AcpRegistryAgent) -> Option { + let AcpRegistryAgent { + id, + name, + description, + icon, + distribution, + } = agent; + + if let Some(target) = current_acp_registry_platform() + .and_then(|platform| distribution.binary.as_ref()?.get(platform)) + { + let binary_name = registry_command_name(&target.cmd, &id); + return Some(AgentConfig { + id, + name, + binary_name, + binary_path: None, + args: target.args.clone(), + env_vars: target.env.clone(), + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Binary), + install_package: Some(target.cmd.clone()), + install_download_url: Some(target.archive.clone()), + install_command: Some(registry_command_name(&target.cmd, "")), + can_install: true, + }); + } + + if let Some(target) = distribution.npx { + let package_name = npm_package_name(&target.package); + let command = npx_command_name(&package_name, &id); + return Some(AgentConfig { + id, + name, + binary_name: command.clone(), + binary_path: None, + args: target.args.clone(), + env_vars: target.env, + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Node), + install_package: Some(target.package), + install_download_url: None, + install_command: Some(command), + can_install: true, + }); + } + + if let Some(target) = distribution.uvx { + let package = python_package_spec_from_uvx(&target.package); + let command = python_command_name(&package, &id); + return Some(AgentConfig { + id, + name, + binary_name: command.clone(), + binary_path: None, + args: target.args, + env_vars: target.env, + default_mode: None, + default_model: None, + icon, + description: Some(description), + installed: false, + install_runtime: Some(AgentRuntime::Python), + install_package: Some(package), + install_download_url: None, + install_command: Some(command), + can_install: true, + }); + } + + None +} + +fn acp_registry_agents_from_index(index: AcpRegistryIndex) -> Vec { + let mut agents = index + .agents + .into_iter() + .filter(|agent| !EXCLUDED_ACP_REGISTRY_AGENT_IDS.contains(&agent.id.as_str())) + .filter_map(acp_registry_agent_to_config) + .collect::>(); + agents.sort_by_key(|agent| agent.name.clone()); + agents +} + +fn acp_registry_cache_path(app_handle: &AppHandle) -> Result { + app_handle + .path() + .app_data_dir() + .map(|dir| dir.join("acp-registry").join("registry.json")) + .map_err(|error| format!("Failed to resolve ACP registry cache path: {}", error)) +} + +fn acp_registry_agents_from_json(json: &str) -> Result, String> { + let registry = serde_json::from_str::(json) + .map_err(|error| format!("Invalid ACP registry: {}", error))?; + Ok(acp_registry_agents_from_index(registry)) +} + +fn load_cached_acp_registry_agents(app_handle: &AppHandle) -> Result, String> { + let cache_path = acp_registry_cache_path(app_handle)?; + let json = fs::read_to_string(&cache_path) + .map_err(|error| format!("Failed to read cached ACP registry: {}", error))?; + acp_registry_agents_from_json(&json) + .map_err(|error| format!("Invalid cached ACP registry: {}", error)) +} + +fn write_acp_registry_cache(app_handle: &AppHandle, json: &str) -> Result<(), String> { + let cache_path = acp_registry_cache_path(app_handle)?; + if let Some(parent) = cache_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("Failed to create ACP registry cache directory: {}", error))?; + } + fs::write(&cache_path, json) + .map_err(|error| format!("Failed to write ACP registry cache: {}", error)) +} + +async fn load_acp_registry_agents(app_handle: &AppHandle) -> Result, String> { + let response = reqwest::Client::new() + .get(acp_registry_url()) + .header(reqwest::header::USER_AGENT, ACP_REGISTRY_USER_AGENT) + .timeout(Duration::from_secs(5)) + .send() + .await + .map_err(|error| format!("Failed to load ACP registry: {}", error))?; + + if !response.status().is_success() { + return Err(format!( + "Failed to load ACP registry: HTTP {}", + response.status() + )); + } + + let json = response + .text() + .await + .map_err(|error| format!("Failed to read ACP registry response: {}", error))?; + + let agents = acp_registry_agents_from_json(&json)?; + if let Err(error) = write_acp_registry_cache(app_handle, &json) { + log::warn!("{}", error); + } + + Ok(agents) +} + +async fn load_preferred_registry_agents( + app_handle: &AppHandle, +) -> Result, String> { + match load_acp_registry_agents(app_handle).await { + Ok(agents) => Ok(agents), + Err(registry_error) => { + log::warn!("{}", registry_error); + load_cached_acp_registry_agents(app_handle).map_err(|cache_error| { + log::warn!("{}", cache_error); + registry_error + }) + } + } +} + +fn merge_agent_catalogs( + mut preferred_agents: Vec, + fallback_agents: Vec, +) -> Vec { + for agent in fallback_agents { + if !preferred_agents + .iter() + .any(|preferred| preferred.id == agent.id) + { + preferred_agents.push(agent); + } + } + preferred_agents.sort_by_key(|agent| agent.name.clone()); + preferred_agents +} + +fn apply_agent_server_settings( + mut agents: Vec, + settings: HashMap, +) -> Vec { + for (id, setting) in settings { + match setting { + AcpAgentServerSetting::Custom { + command, + args, + env, + default_mode, + default_model, + } => { + if command.trim().is_empty() { + log::warn!("Skipping custom ACP agent '{}' with an empty command", id); + continue; + } + let binary_name = registry_command_name(&command, &id); + let custom_agent = AgentConfig { + id: id.clone(), + name: id, + binary_name, + binary_path: Some(expand_home(&command)), + args, + env_vars: env, + default_mode: clean_setting(default_mode), + default_model: clean_setting(default_model), + icon: None, + description: Some("Custom ACP agent from Athas settings".to_string()), + installed: false, + install_runtime: None, + install_package: None, + install_download_url: None, + install_command: None, + can_install: false, + }; + upsert_agent(&mut agents, custom_agent); + } + AcpAgentServerSetting::Registry { + env, + default_mode, + default_model, + } => { + let Some(agent) = agents + .iter_mut() + .find(|agent| agent.id == id || agent.name == id) + else { + log::debug!("Configured ACP registry agent '{}' was not found", id); + continue; + }; + agent.env_vars.extend(env); + agent.default_mode = clean_setting(default_mode); + agent.default_model = clean_setting(default_model); + } + } + } + + agents.sort_by_key(|agent| agent.name.clone()); + agents +} + +fn clean_setting(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +fn expand_home(path: &str) -> String { + if path == "~" { + return std::env::var("HOME").unwrap_or_else(|_| path.to_string()); + } + if let Some(rest) = path.strip_prefix("~/") { + if let Ok(home) = std::env::var("HOME") { + return Path::new(&home).join(rest).to_string_lossy().to_string(); + } + } + path.to_string() +} + +fn upsert_agent(agents: &mut Vec, agent: AgentConfig) { + if let Some(existing) = agents.iter_mut().find(|existing| existing.id == agent.id) { + *existing = agent; + } else { + agents.push(agent); + } +} + +fn load_agent_server_settings( + app_handle: &AppHandle, +) -> Result, String> { + let store = app_handle + .store("settings.json") + .map_err(|error| format!("Failed to load settings store: {}", error))?; + let Some(value) = store.get("agentServers") else { + return Ok(HashMap::new()); + }; + let raw_settings = serde_json::from_value::>(value) + .map_err(|error| format!("Invalid agentServers settings: {}", error))?; + let mut settings = HashMap::new(); + + for (id, value) in raw_settings { + match serde_json::from_value::(value) { + Ok(setting) => { + settings.insert(id, setting); + } + Err(error) => { + log::warn!("Skipping invalid ACP agent setting '{}': {}", id, error); + } + } + } + + Ok(settings) +} + +async fn load_marketplace_agents(app_handle: &AppHandle) -> Result, String> { let cache = AGENT_CATALOG_CACHE.get_or_init(|| std::sync::Mutex::new(None)); { let cached = cache @@ -226,10 +707,55 @@ async fn load_marketplace_agents() -> Result, String> { if let Some(catalog) = cached.as_ref() && catalog.loaded_at.elapsed() < Duration::from_secs(AGENT_CATALOG_CACHE_SECONDS) { - return Ok(catalog.agents.clone()); + let agent_settings = load_agent_server_settings(app_handle).map_err(|error| { + log::warn!("{}", error); + error + })?; + return Ok(apply_agent_server_settings( + catalog.agents.clone(), + agent_settings, + )); } } + let registry_agents = load_preferred_registry_agents(app_handle).await; + let legacy_agents = load_legacy_marketplace_agents().await; + let agents = match (registry_agents, legacy_agents) { + (Ok(registry_agents), Ok(legacy_agents)) => { + merge_agent_catalogs(registry_agents, legacy_agents) + } + (Ok(registry_agents), Err(legacy_error)) => { + log::warn!("{}", legacy_error); + registry_agents + } + (Err(registry_error), Ok(legacy_agents)) => { + log::warn!("{}", registry_error); + legacy_agents + } + (Err(registry_error), Err(legacy_error)) => { + return Err(format!( + "{}; legacy agent catalog also failed: {}", + registry_error, legacy_error + )); + } + }; + + let mut cached = cache + .lock() + .map_err(|_| "Agent catalog cache poisoned".to_string())?; + *cached = Some(CachedAgentCatalog { + loaded_at: Instant::now(), + agents: agents.clone(), + }); + + let agent_settings = load_agent_server_settings(app_handle).map_err(|error| { + log::warn!("{}", error); + error + })?; + Ok(apply_agent_server_settings(agents, agent_settings)) +} + +async fn load_legacy_marketplace_agents() -> Result, String> { let response = reqwest::Client::new() .get(extensions_manifest_url()) .timeout(Duration::from_secs(5)) @@ -256,19 +782,11 @@ async fn load_marketplace_agents() -> Result, String> { .collect::>(); agents.sort_by_key(|agent| agent.name.clone()); - let mut cached = cache - .lock() - .map_err(|_| "Agent catalog cache poisoned".to_string())?; - *cached = Some(CachedAgentCatalog { - loaded_at: Instant::now(), - agents: agents.clone(), - }); - Ok(agents) } -async fn refresh_registered_agents(bridge: &mut AcpAgentBridge) { - match load_marketplace_agents().await { +async fn refresh_registered_agents(app_handle: &AppHandle, bridge: &mut AcpAgentBridge) { + match load_marketplace_agents(app_handle).await { Ok(agents) => bridge.replace_registered_agents(agents), Err(error) => { log::warn!("{}", error); @@ -534,3 +1052,304 @@ fn make_wrapper_executable(path: &PathBuf) -> Result<(), String> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn agent(id: &str, name: &str, binary_name: &str) -> AgentConfig { + AgentConfig { + id: id.to_string(), + name: name.to_string(), + binary_name: binary_name.to_string(), + binary_path: None, + args: Vec::new(), + env_vars: HashMap::new(), + default_mode: None, + default_model: None, + icon: None, + description: None, + installed: false, + install_runtime: None, + install_package: None, + install_download_url: None, + install_command: None, + can_install: false, + } + } + + #[test] + fn merge_agent_catalogs_prefers_registry_and_preserves_legacy_only_agents() { + let mut registry = agent("codex-acp", "Codex Registry", "npx"); + registry.args = vec!["-y".to_string(), "@vendor/codex-acp".to_string()]; + let legacy = agent("codex-acp", "Codex Legacy", "codex-acp"); + let legacy_only = agent("athas-local", "Athas Local", "athas-local"); + + let merged = merge_agent_catalogs(vec![registry], vec![legacy, legacy_only]); + + assert_eq!(merged.len(), 2); + let codex = merged + .iter() + .find(|candidate| candidate.id == "codex-acp") + .expect("codex agent"); + assert_eq!(codex.name, "Codex Registry"); + assert_eq!(codex.binary_name, "npx"); + assert!(merged.iter().any(|candidate| candidate.id == "athas-local")); + } + + #[test] + fn registry_settings_override_env_and_defaults_without_dropping_agent() { + let mut base = agent("codex-acp", "Codex", "npx"); + base.env_vars.insert( + "BASE_URL".to_string(), + "https://registry.example".to_string(), + ); + base + .env_vars + .insert("KEEP_ME".to_string(), "registry".to_string()); + + let mut env = HashMap::new(); + env.insert("BASE_URL".to_string(), "https://user.example".to_string()); + env.insert("USER_ONLY".to_string(), "true".to_string()); + let mut settings = HashMap::new(); + settings.insert( + "codex-acp".to_string(), + AcpAgentServerSetting::Registry { + env, + default_mode: Some("plan".to_string()), + default_model: Some("gpt-5.5".to_string()), + }, + ); + + let agents = apply_agent_server_settings(vec![base], settings); + let codex = agents.first().expect("codex agent"); + + assert_eq!( + codex.env_vars.get("BASE_URL").map(String::as_str), + Some("https://user.example") + ); + assert_eq!( + codex.env_vars.get("KEEP_ME").map(String::as_str), + Some("registry") + ); + assert_eq!( + codex.env_vars.get("USER_ONLY").map(String::as_str), + Some("true") + ); + assert_eq!(codex.default_mode.as_deref(), Some("plan")); + assert_eq!(codex.default_model.as_deref(), Some("gpt-5.5")); + } + + #[test] + fn custom_agent_settings_create_runnable_agent_config() { + let mut env = HashMap::new(); + env.insert("CUSTOM_TOKEN".to_string(), "secret".to_string()); + let mut settings = HashMap::new(); + settings.insert( + "my-agent".to_string(), + AcpAgentServerSetting::Custom { + command: "/usr/local/bin/my-agent".to_string(), + args: vec!["--acp".to_string()], + env, + default_mode: Some(" act ".to_string()), + default_model: Some(" custom-model ".to_string()), + }, + ); + + let agents = apply_agent_server_settings(Vec::new(), settings); + let custom = agents.first().expect("custom agent"); + + assert_eq!(custom.id, "my-agent"); + assert_eq!(custom.name, "my-agent"); + assert_eq!(custom.binary_name, "my-agent"); + assert_eq!( + custom.binary_path.as_deref(), + Some("/usr/local/bin/my-agent") + ); + assert_eq!(custom.args, vec!["--acp"]); + assert_eq!( + custom.env_vars.get("CUSTOM_TOKEN").map(String::as_str), + Some("secret") + ); + assert_eq!(custom.default_mode.as_deref(), Some("act")); + assert_eq!(custom.default_model.as_deref(), Some("custom-model")); + assert!(!custom.can_install); + } + + #[test] + fn malformed_custom_agent_settings_are_skipped() { + let mut settings = HashMap::new(); + settings.insert( + "broken".to_string(), + AcpAgentServerSetting::Custom { + command: " ".to_string(), + args: Vec::new(), + env: HashMap::new(), + default_mode: None, + default_model: None, + }, + ); + + let agents = apply_agent_server_settings(Vec::new(), settings); + + assert!(agents.is_empty()); + } + + #[test] + fn npm_package_name_strips_registry_version_specs() { + assert_eq!(npm_package_name("cline@2.18.0"), "cline"); + assert_eq!( + npm_package_name("@agentclientprotocol/claude-agent-acp@0.33.1"), + "@agentclientprotocol/claude-agent-acp" + ); + assert_eq!(npm_package_name("@scope/package"), "@scope/package"); + } + + #[test] + fn npx_command_name_uses_known_package_binary_aliases() { + assert_eq!(npx_command_name("@google/gemini-cli", "gemini"), "gemini"); + assert_eq!( + npx_command_name("@qwen-code/qwen-code", "qwen-code"), + "qwen" + ); + assert_eq!( + npx_command_name("@tencent-ai/codebuddy-code", "codebuddy-code"), + "codebuddy" + ); + assert_eq!(npx_command_name("dirac-cli", "dirac"), "dirac"); + assert_eq!(npx_command_name("cline", "cline"), "cline"); + } + + #[test] + fn python_package_spec_from_uvx_converts_registry_version_specs() { + assert_eq!( + python_package_spec_from_uvx("fast-agent-acp==0.7.1"), + "fast-agent-mcp==0.7.1" + ); + assert_eq!( + python_package_spec_from_uvx("minion-code@0.1.44"), + "minion-code==0.1.44" + ); + } + + #[test] + fn python_command_name_uses_fast_agent_acp_entrypoint() { + assert_eq!( + python_command_name("fast-agent-mcp==0.7.1", "fast-agent"), + "fast-agent-acp" + ); + assert_eq!( + python_command_name("minion-code==0.1.44", "minion-code"), + "minion-code" + ); + } + + #[test] + fn acp_registry_json_maps_npx_distribution_as_managed_node_install() { + let json = r#"{ + "agents": [ + { + "id": "qwen-code", + "name": "Qwen Code", + "description": "Qwen ACP adapter", + "icon": "codex.svg", + "distribution": { + "npx": { + "package": "@qwen-code/qwen-code@0.15.9", + "args": ["--acp"], + "env": { "REGISTRY_ENV": "1" } + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + let qwen = agents.first().expect("qwen agent"); + + assert_eq!(qwen.id, "qwen-code"); + assert_eq!(qwen.binary_name, "qwen"); + assert_eq!(qwen.args, vec!["--acp".to_string()]); + assert_eq!(qwen.install_runtime, Some(AgentRuntime::Node)); + assert_eq!( + qwen.install_package.as_deref(), + Some("@qwen-code/qwen-code@0.15.9") + ); + assert_eq!(qwen.install_command.as_deref(), Some("qwen")); + assert!(qwen.can_install); + assert_eq!( + qwen.env_vars.get("REGISTRY_ENV").map(String::as_str), + Some("1") + ); + } + + #[test] + fn acp_registry_json_skips_excluded_agents() { + let json = r#"{ + "agents": [ + { + "id": "agoragentic-acp", + "name": "Agoragentic", + "description": "Marketplace adapter", + "distribution": { + "npx": { + "package": "agoragentic-mcp@1.3.0", + "args": ["--acp"] + } + } + }, + { + "id": "codex-acp", + "name": "Codex", + "description": "Codex ACP adapter", + "distribution": { + "npx": { + "package": "@vendor/codex-acp" + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + + assert_eq!(agents.len(), 1); + assert_eq!( + agents.first().map(|agent| agent.id.as_str()), + Some("codex-acp") + ); + } + + #[test] + fn acp_registry_json_maps_uvx_distribution_as_managed_python_install() { + let json = r#"{ + "agents": [ + { + "id": "minion-code", + "name": "Minion Code", + "description": "Minion ACP adapter", + "distribution": { + "uvx": { + "package": "minion-code@0.1.44", + "args": ["acp"] + } + } + } + ] + }"#; + + let agents = acp_registry_agents_from_json(json).expect("registry agents"); + let minion = agents.first().expect("minion agent"); + + assert_eq!(minion.id, "minion-code"); + assert_eq!(minion.binary_name, "minion-code"); + assert_eq!(minion.args, vec!["acp".to_string()]); + assert_eq!(minion.install_runtime, Some(AgentRuntime::Python)); + assert_eq!( + minion.install_package.as_deref(), + Some("minion-code==0.1.44") + ); + assert_eq!(minion.install_command.as_deref(), Some("minion-code")); + assert!(minion.can_install); + } +} diff --git a/src/features/ai/components/selectors/agent-selector.tsx b/src/features/ai/components/selectors/agent-selector.tsx index d1592b926..585a9e025 100644 --- a/src/features/ai/components/selectors/agent-selector.tsx +++ b/src/features/ai/components/selectors/agent-selector.tsx @@ -86,6 +86,12 @@ export function AgentSelector({ void loadInstalledAgents(); }, [loadInstalledAgents]); + useEffect(() => { + if (isOpen) { + void loadInstalledAgents(); + } + }, [isOpen, loadInstalledAgents]); + // Build filtered items list const filteredItems = useMemo(() => { const items: Array<{ diff --git a/src/features/ai/types/acp.ts b/src/features/ai/types/acp.ts index aea4140de..40e06fdac 100644 --- a/src/features/ai/types/acp.ts +++ b/src/features/ai/types/acp.ts @@ -7,6 +7,8 @@ export interface AgentConfig { binaryPath: string | null; args: string[]; envVars: Record; + defaultMode: string | null; + defaultModel: string | null; icon: string | null; description: string | null; installed: boolean; diff --git a/src/features/settings/config/default-settings.ts b/src/features/settings/config/default-settings.ts index 6b047292f..367aee192 100644 --- a/src/features/settings/config/default-settings.ts +++ b/src/features/settings/config/default-settings.ts @@ -76,6 +76,7 @@ export const defaultSettings: Settings = { aiAutocompleteCustomModelId: "", aiDefaultSessionMode: "", aiSkills: [], + agentServers: {}, ollamaBaseUrl: "http://localhost:11434", // Layout sidebarWidth: 220, @@ -164,6 +165,9 @@ export function getDefaultSettingsSnapshot(): Settings { footerLeadingItemsOrder: [...defaultSettings.footerLeadingItemsOrder], footerTrailingItemsOrder: [...defaultSettings.footerTrailingItemsOrder], aiSkills: defaultSettings.aiSkills.map((skill) => ({ ...skill })), + agentServers: Object.fromEntries( + Object.entries(defaultSettings.agentServers).map(([id, agent]) => [id, { ...agent }]), + ), uiFontSize: normalizeUiFontSize(defaultSettings.uiFontSize), }; } diff --git a/src/features/settings/lib/settings-normalization.ts b/src/features/settings/lib/settings-normalization.ts index f5048a080..3ec31da26 100644 --- a/src/features/settings/lib/settings-normalization.ts +++ b/src/features/settings/lib/settings-normalization.ts @@ -77,6 +77,11 @@ function normalizeBaseUrl(value: string | undefined): string { } const MAX_SYNCED_AI_SKILLS = 200; +const MAX_AGENT_SERVERS = 100; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} function normalizeAISkills(skills: Settings["aiSkills"]): Settings["aiSkills"] { if (!Array.isArray(skills)) { @@ -146,6 +151,83 @@ function normalizeAISkills(skills: Settings["aiSkills"]): Settings["aiSkills"] { })); } +function normalizeEnv(value: unknown): Record | undefined { + if (!isRecord(value)) { + return undefined; + } + + const env = Object.fromEntries( + Object.entries(value) + .filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && + entry[0].trim().length > 0 && + typeof entry[1] === "string", + ) + .map(([key, envValue]) => [key.trim().slice(0, 160), envValue]), + ); + + return Object.keys(env).length > 0 ? env : undefined; +} + +function normalizeAgentServers(value: Settings["agentServers"]): Settings["agentServers"] { + if (!isRecord(value)) { + return {}; + } + + const entries: Array<[string, Settings["agentServers"][string]]> = []; + + for (const [id, server] of Object.entries(value) + .filter(([id, server]) => typeof id === "string" && id.trim().length > 0 && isRecord(server)) + .slice(0, MAX_AGENT_SERVERS)) { + const trimmedId = id.trim().slice(0, 160); + const env = normalizeEnv(server.env); + const defaultMode = + typeof server.defaultMode === "string" && server.defaultMode.trim() + ? server.defaultMode.trim().slice(0, 160) + : undefined; + const defaultModel = + typeof server.defaultModel === "string" && server.defaultModel.trim() + ? server.defaultModel.trim().slice(0, 240) + : undefined; + + if (server.type === "custom") { + if (typeof server.command !== "string" || !server.command.trim()) { + continue; + } + const args = Array.isArray(server.args) + ? server.args.filter((arg): arg is string => typeof arg === "string") + : undefined; + entries.push([ + trimmedId, + { + type: "custom", + command: server.command.trim(), + ...(args ? { args } : {}), + ...(env ? { env } : {}), + ...(defaultMode ? { defaultMode } : {}), + ...(defaultModel ? { defaultModel } : {}), + }, + ]); + continue; + } + + if (server.type === "registry") { + entries.push([ + trimmedId, + { + type: "registry", + ...(env ? { env } : {}), + ...(defaultMode ? { defaultMode } : {}), + ...(defaultModel ? { defaultModel } : {}), + }, + ]); + } + } + + return Object.fromEntries(entries); +} + function normalizeAISettings(settings: Settings): Settings { const normalizedSettings = { ...settings }; const provider = @@ -191,6 +273,7 @@ function normalizeAISettings(settings: Settings): Settings { normalizedSettings.aiAutocompleteCustomModelId = normalizedSettings.aiAutocompleteCustomModelId?.trim() || ""; normalizedSettings.aiSkills = normalizeAISkills(normalizedSettings.aiSkills); + normalizedSettings.agentServers = normalizeAgentServers(normalizedSettings.agentServers); return normalizedSettings; } @@ -310,6 +393,10 @@ export function normalizeSettingValue( return normalizeAISkills(value as Settings["aiSkills"]) as Settings[K]; } + if (key === "agentServers") { + return normalizeAgentServers(value as Settings["agentServers"]) as Settings[K]; + } + if (key === "aiCustomBaseUrl") { return normalizeBaseUrl(value as string) as Settings[K]; } diff --git a/src/features/settings/tests/settings-import-export.test.ts b/src/features/settings/tests/settings-import-export.test.ts index 522a68667..e358c79be 100644 --- a/src/features/settings/tests/settings-import-export.test.ts +++ b/src/features/settings/tests/settings-import-export.test.ts @@ -46,4 +46,28 @@ describe("settings import/export", () => { expect(imported?.wordWrap).toBe(true); }); + + it("imports ACP agent server settings", () => { + const imported = parseSettingsImportJson( + JSON.stringify({ + agentServers: { + "codex-acp": { + type: "registry", + env: { + CODEX_HOME: "/tmp/codex", + }, + defaultMode: "plan", + }, + }, + }), + ); + + expect(imported?.agentServers["codex-acp"]).toEqual({ + type: "registry", + env: { + CODEX_HOME: "/tmp/codex", + }, + defaultMode: "plan", + }); + }); }); diff --git a/src/features/settings/tests/settings-normalization.test.ts b/src/features/settings/tests/settings-normalization.test.ts index a8585e750..5ab51a89e 100644 --- a/src/features/settings/tests/settings-normalization.test.ts +++ b/src/features/settings/tests/settings-normalization.test.ts @@ -112,4 +112,51 @@ describe("settings normalization", () => { upstreamUpdatedAt: "2026-04-01T00:00:00.000Z", }); }); + + it("normalizes ACP agent server settings", () => { + const normalized = normalizeSettingValue("agentServers", { + " codex-acp ": { + type: "registry", + env: { + CODEX_API_KEY: "secret", + EMPTY_ALLOWED: "", + ignored: 1, + }, + defaultMode: " plan ", + defaultModel: " gpt-5.5 ", + }, + " custom-agent ": { + type: "custom", + command: " node ", + args: ["agent.js", 123], + env: { + CUSTOM_ENV: "1", + }, + }, + broken: { + type: "custom", + command: " ", + }, + } as unknown as ReturnType["agentServers"]); + + expect(normalized).toEqual({ + "codex-acp": { + type: "registry", + env: { + CODEX_API_KEY: "secret", + EMPTY_ALLOWED: "", + }, + defaultMode: "plan", + defaultModel: "gpt-5.5", + }, + "custom-agent": { + type: "custom", + command: "node", + args: ["agent.js"], + env: { + CUSTOM_ENV: "1", + }, + }, + }); + }); }); diff --git a/src/features/settings/types/settings.ts b/src/features/settings/types/settings.ts index 1728947ef..3c5f531eb 100644 --- a/src/features/settings/types/settings.ts +++ b/src/features/settings/types/settings.ts @@ -9,6 +9,22 @@ import type { export type Theme = string; +export type AcpAgentServerSettings = + | { + type: "custom"; + command: string; + args?: string[]; + env?: Record; + defaultMode?: string; + defaultModel?: string; + } + | { + type: "registry"; + env?: Record; + defaultMode?: string; + defaultModel?: string; + }; + export interface Settings { // General autoSave: boolean; @@ -65,6 +81,7 @@ export interface Settings { aiAutocompleteCustomModelId: string; aiDefaultSessionMode: string; aiSkills: AIChatSkill[]; + agentServers: Record; ollamaBaseUrl: string; // Layout sidebarWidth: number;