use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; use serde::Deserialize; use tauri::State; use crate::acp::binary_cache; use crate::acp::error::AcpError; use crate::acp::manager::ConnectionManager; use crate::acp::preflight::{self, PreflightResult}; use crate::acp::registry; use crate::acp::types::{ AcpAgentInfo, AgentSkillContent, AgentSkillItem, AgentSkillLayout, AgentSkillLocation, AgentSkillScope, AgentSkillsListResult, ConnectionInfo, PromptInputBlock, }; use crate::db::service::agent_setting_service; use crate::db::AppDatabase; use crate::models::agent::AgentType; fn parse_version_output(output: &std::process::Output) -> Option { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); let mut first_non_empty: Option = None; for raw_line in stdout.lines().chain(stderr.lines()) { let line = raw_line.trim(); if line.is_empty() { continue; } if first_non_empty.is_none() { first_non_empty = Some(line.to_string()); } for raw_token in line.split_whitespace() { let token = raw_token.trim_matches(|c: char| { !(c.is_ascii_alphanumeric() || c == '.' || c == '@' || c == '-' || c == '_') }); if token.is_empty() { continue; } let candidate = token .rsplit('@') .next() .unwrap_or(token) .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '-') .trim_start_matches('v'); let is_version_like = candidate.chars().any(|c| c.is_ascii_digit()) && candidate.contains('.'); if is_version_like { return Some(candidate.to_string()); } } } first_non_empty } fn is_version_like(value: &str) -> bool { value.chars().any(|c| c.is_ascii_digit()) && value.contains('.') } fn normalize_version_candidate(value: &str) -> Option { let normalized = value.trim().trim_start_matches('v'); if is_version_like(normalized) { Some(normalized.to_string()) } else { None } } fn version_from_package_spec(package: &str) -> Option { let (_, maybe_version) = package.rsplit_once('@')?; let version = maybe_version.trim(); if version.is_empty() || version.eq_ignore_ascii_case("latest") { return None; } normalize_version_candidate(version) } fn package_name_from_spec(package: &str) -> String { let normalized = package.trim(); if normalized.is_empty() { return String::new(); } if let Some(index) = normalized.rfind('@') { if index > 0 { let version_part = normalized[index + 1..].trim(); if !version_part.is_empty() { return normalized[..index].to_string(); } } } normalized.to_string() } #[derive(Deserialize)] #[serde(untagged)] enum NpmPackageBin { Single(String), Multiple(BTreeMap), } #[derive(Deserialize)] struct NpmPackageManifest { version: Option, bin: Option, } fn read_npx_cached_package_version(package_dir: &Path) -> Option { let manifest_path = package_dir.join("package.json"); let content = std::fs::read_to_string(manifest_path).ok()?; let manifest: NpmPackageManifest = serde_json::from_str(&content).ok()?; manifest .version .as_deref() .and_then(normalize_version_candidate) } fn read_npx_cached_package_manifest(package_dir: &Path) -> Option { let manifest_path = package_dir.join("package.json"); let content = std::fs::read_to_string(manifest_path).ok()?; serde_json::from_str(&content).ok() } fn npx_package_parts(package: &str) -> Vec { package_name_from_spec(package) .split('/') .filter(|part| !part.is_empty()) .map(ToString::to_string) .collect() } fn npx_cached_package_dirs(cache_dir: &Path, package: &str) -> Vec { let package_parts = npx_package_parts(package); if package_parts.is_empty() { return vec![]; } let npx_root = cache_dir.join("_npx"); let Ok(entries) = std::fs::read_dir(&npx_root) else { return vec![]; }; let mut dirs = Vec::new(); for entry in entries.flatten() { let root = entry.path(); if !root.is_dir() { continue; } let mut package_dir = root.join("node_modules"); for part in &package_parts { package_dir = package_dir.join(part); } if package_dir.is_dir() { dirs.push(package_dir); } } dirs } #[cfg(unix)] fn ensure_executable(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let metadata = std::fs::metadata(path)?; let mut permissions = metadata.permissions(); let current = permissions.mode(); let next = current | 0o111; if next != current { permissions.set_mode(next); std::fs::set_permissions(path, permissions)?; } Ok(()) } #[cfg(not(unix))] fn ensure_executable(_path: &Path) -> std::io::Result<()> { Ok(()) } async fn ensure_npx_cached_bins_executable(package: &str) -> Result<(), AcpError> { let Some(cache_dir) = npm_cache_dir().await else { return Ok(()); }; for package_dir in npx_cached_package_dirs(&cache_dir, package) { let Some(manifest) = read_npx_cached_package_manifest(&package_dir) else { continue; }; let mut bin_rel_paths = Vec::new(); match manifest.bin { Some(NpmPackageBin::Single(path)) => bin_rel_paths.push(path), Some(NpmPackageBin::Multiple(map)) => { bin_rel_paths.extend(map.into_values()); } None => {} } for rel_path in bin_rel_paths { let script_path = package_dir.join(rel_path); if !script_path.is_file() { continue; } if let Err(e) = ensure_executable(&script_path) { return Err(AcpError::protocol(format!( "failed to set executable permission for npx package script: {e}" ))); } } } Ok(()) } async fn detect_npx_cached_version(package: &str) -> Option { let cache_dir = npm_cache_dir().await?; let expected = version_from_package_spec(package); let mut detected = None; for package_dir in npx_cached_package_dirs(&cache_dir, package) { let version = read_npx_cached_package_version(&package_dir).or_else(|| expected.clone()); if let Some(found) = version { if expected.as_deref() == Some(found.as_str()) { return Some(found); } if detected.is_none() { detected = Some(found); } } } detected } async fn detect_uvx_cached_version(package: &str) -> Option { let output = crate::process::tokio_command("uvx") .arg(package) .arg("--version") .output() .await .ok()?; if !output.status.success() { return None; } parse_version_output(&output).and_then(|value| normalize_version_candidate(&value)) } async fn detect_local_version(agent_type: AgentType) -> Option { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Npx { package, .. } => { detect_npx_cached_version(package).await } registry::AgentDistribution::Uvx { package, .. } => detect_uvx_cached_version(package) .await .or_else(|| version_from_package_spec(package)), registry::AgentDistribution::Binary { cmd, .. } => { binary_cache::detect_installed_version(agent_type, cmd) .ok() .flatten() } } } async fn prepare_npx_package(package: &str) -> Result<(), AcpError> { let output = crate::process::tokio_command("npx") .arg("--yes") .arg("--package") .arg(package) .arg("--") .arg("node") .arg("-e") .arg("process.exit(0)") .output() .await .map_err(|e| AcpError::protocol(format!("failed to run npx: {e}")))?; if !output.status.success() { let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); let msg = if err.is_empty() { "failed to prepare npx package".to_string() } else { format!("failed to prepare npx package: {err}") }; return Err(AcpError::protocol(msg)); } // Some npm packages ship bin scripts without executable bit. // Normalize permissions in local npx cache to avoid runtime spawn failures. ensure_npx_cached_bins_executable(package).await?; Ok(()) } async fn prepare_uvx_package(package: &str) -> Result<(), AcpError> { let output = crate::process::tokio_command("uvx") .arg(package) .arg("--version") .output() .await .map_err(|e| AcpError::protocol(format!("failed to run uvx: {e}")))?; if !output.status.success() { let err = String::from_utf8_lossy(&output.stderr).trim().to_string(); let msg = if err.is_empty() { "failed to prepare uvx package".to_string() } else { format!("failed to prepare uvx package: {err}") }; return Err(AcpError::protocol(msg)); } Ok(()) } async fn npm_cache_dir() -> Option { let output = crate::process::tokio_command("npm") .arg("config") .arg("get") .arg("cache") .output() .await .ok()?; if !output.status.success() { return None; } let raw = String::from_utf8_lossy(&output.stdout).trim().to_string(); if raw.is_empty() || raw.eq_ignore_ascii_case("undefined") { return None; } Some(PathBuf::from(raw)) } fn remove_npx_package_cache(cache_dir: &Path, package_name: &str) -> Result<(), AcpError> { let npx_root = cache_dir.join("_npx"); if !npx_root.exists() { return Ok(()); } let package_parts = package_name .split('/') .filter(|part| !part.is_empty()) .collect::>(); if package_parts.is_empty() { return Ok(()); } let entries = std::fs::read_dir(&npx_root) .map_err(|e| AcpError::protocol(format!("failed to read npx cache directory: {e}")))?; for entry in entries.flatten() { let root = entry.path(); if !root.is_dir() { continue; } let mut package_dir = root.join("node_modules"); for part in &package_parts { package_dir = package_dir.join(part); } if package_dir.exists() { std::fs::remove_dir_all(&package_dir).map_err(|e| { AcpError::protocol(format!("failed to remove npx package cache: {e}")) })?; } } Ok(()) } async fn uninstall_npx_package(package: &str) -> Result<(), AcpError> { let package_name = package_name_from_spec(package); if !package_name.is_empty() { // Best effort: if package was installed globally, remove it as well. let _ = crate::process::tokio_command("npm") .arg("uninstall") .arg("-g") .arg(&package_name) .output() .await; } if let Some(cache_dir) = npm_cache_dir().await { remove_npx_package_cache(&cache_dir, &package_name)?; } Ok(()) } async fn uninstall_uvx_package(package: &str) -> Result<(), AcpError> { let package_name = package_name_from_spec(package); if package_name.is_empty() { return Ok(()); } // Best effort: remove package cache and any explicitly installed tool. let _ = crate::process::tokio_command("uv") .arg("cache") .arg("clean") .arg(&package_name) .output() .await; let _ = crate::process::tokio_command("uv") .arg("tool") .arg("uninstall") .arg(&package_name) .output() .await; Ok(()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SkillStorageKind { SkillDirectoryOnly, SkillDirectoryOrMarkdownFile, } #[derive(Debug, Clone)] struct SkillStorageSpec { kind: SkillStorageKind, global_dirs: Vec, project_rel_dirs: Vec<&'static str>, } fn home_dir_or_default() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } fn codex_home_dir() -> PathBuf { let configured = std::env::var("CODEX_HOME").ok().and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); match configured { Some(value) => { if value == "~" { home_dir_or_default() } else if let Some(remain) = value.strip_prefix("~/") { home_dir_or_default().join(remain) } else { PathBuf::from(value) } } None => home_dir_or_default().join(".codex"), } } fn codex_config_toml_path() -> PathBuf { codex_home_dir().join("config.toml") } fn codex_auth_json_path() -> PathBuf { codex_home_dir().join("auth.json") } fn opencode_primary_config_path() -> PathBuf { home_dir_or_default() .join(".config") .join("opencode") .join("opencode.json") } fn opencode_legacy_config_path() -> PathBuf { home_dir_or_default() .join(".config") .join("opencode") .join("config.json") } fn resolve_opencode_config_path() -> PathBuf { let primary = opencode_primary_config_path(); if primary.exists() { return primary; } let legacy = opencode_legacy_config_path(); if legacy.exists() { return legacy; } primary } fn opencode_auth_json_path() -> PathBuf { home_dir_or_default() .join(".local") .join("share") .join("opencode") .join("auth.json") } fn load_opencode_auth_json_raw() -> Option { fs::read_to_string(opencode_auth_json_path()).ok() } fn load_codex_auth_json_raw() -> Option { fs::read_to_string(codex_auth_json_path()).ok() } fn load_codex_config_toml_raw() -> Option { fs::read_to_string(codex_config_toml_path()).ok() } fn load_codex_local_config_json() -> Option { let mut merged = serde_json::Map::new(); if let Ok(raw_toml) = fs::read_to_string(codex_config_toml_path()) { if let Ok(value) = raw_toml.parse::() { if let Some(model) = value .get("model") .and_then(|item| item.as_str()) .map(str::trim) .filter(|item| !item.is_empty()) { merged.insert( "model".to_string(), serde_json::Value::String(model.to_string()), ); } let model_provider = value .get("model_provider") .and_then(|item| item.as_str()) .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string); let mut api_base_url: Option = None; if let Some(provider) = model_provider { api_base_url = value .get("model_providers") .and_then(|table| table.get(provider.as_str())) .and_then(|table| table.get("base_url")) .and_then(|item| item.as_str()) .map(str::trim) .filter(|item| !item.is_empty()) .map(str::to_string); } if api_base_url.is_none() { api_base_url = value .get("model_providers") .and_then(|table| table.as_table()) .and_then(|providers| { providers.values().find_map(|item| { item.get("base_url") .and_then(|base| base.as_str()) .map(str::trim) .filter(|base| !base.is_empty()) .map(str::to_string) }) }); } if let Some(base_url) = api_base_url { merged.insert( "apiBaseUrl".to_string(), serde_json::Value::String(base_url), ); } if let Some(env) = value.get("env").and_then(|item| item.as_table()) { let mut env_map = serde_json::Map::new(); for (key, item) in env { let Some(raw) = item.as_str() else { continue; }; let trimmed = raw.trim(); if trimmed.is_empty() { continue; } env_map.insert( key.to_string(), serde_json::Value::String(trimmed.to_string()), ); } if !env_map.is_empty() { merged.insert("env".to_string(), serde_json::Value::Object(env_map)); } } } } if let Ok(raw_auth) = fs::read_to_string(codex_auth_json_path()) { if let Ok(auth) = serde_json::from_str::(&raw_auth) { if let Some(api_key) = auth .get("OPENAI_API_KEY") .and_then(|item| item.as_str()) .map(str::trim) .filter(|item| !item.is_empty()) { merged.insert( "apiKey".to_string(), serde_json::Value::String(api_key.to_string()), ); } } } if merged.is_empty() { return None; } serde_json::to_string_pretty(&serde_json::Value::Object(merged)).ok() } fn persist_codex_local_config(config_patch_json: Option<&str>) -> Result<(), AcpError> { let Some(raw_patch) = config_patch_json else { return Ok(()); }; let runtime = serde_json::from_str::(raw_patch) .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; let AgentRuntimeConfig { api_base_url, api_key, model, env, } = runtime; let config_path = codex_config_toml_path(); let mut toml_value = if config_path.exists() { match fs::read_to_string(&config_path) .ok() .and_then(|raw| raw.parse::().ok()) { Some(existing) if existing.is_table() => existing, _ => toml::Value::Table(toml::map::Map::new()), } } else { toml::Value::Table(toml::map::Map::new()) }; let table = toml_value .as_table_mut() .ok_or_else(|| AcpError::protocol("codex config root must be a TOML table"))?; match trim_non_empty(model) { Some(model) => { table.insert("model".to_string(), toml::Value::String(model)); } None => { table.remove("model"); } } let provider_name = table .get("model_provider") .and_then(|value| value.as_str()) .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| "codeg".to_string()); table.insert( "model_provider".to_string(), toml::Value::String(provider_name.clone()), ); let providers_item = table .entry("model_providers".to_string()) .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); if !providers_item.is_table() { *providers_item = toml::Value::Table(toml::map::Map::new()); } let providers = providers_item .as_table_mut() .ok_or_else(|| AcpError::protocol("invalid model_providers table"))?; let provider_item = providers .entry(provider_name.clone()) .or_insert_with(|| toml::Value::Table(toml::map::Map::new())); if !provider_item.is_table() { *provider_item = toml::Value::Table(toml::map::Map::new()); } let provider_table = provider_item .as_table_mut() .ok_or_else(|| AcpError::protocol("invalid model provider table"))?; match trim_non_empty(api_base_url) { Some(base_url) => { provider_table.insert("base_url".to_string(), toml::Value::String(base_url)); } None => { provider_table.remove("base_url"); } } if provider_name == "codeg" { provider_table.insert("name".to_string(), toml::Value::String("codeg".to_string())); provider_table.insert( "wire_api".to_string(), toml::Value::String("responses".to_string()), ); provider_table.insert( "requires_openai_auth".to_string(), toml::Value::Boolean(true), ); } if env.is_empty() { table.remove("env"); } else { let mut env_table = toml::map::Map::new(); for (key, value) in env { let trimmed = value.trim(); if trimmed.is_empty() { continue; } env_table.insert(key, toml::Value::String(trimmed.to_string())); } if env_table.is_empty() { table.remove("env"); } else { table.insert("env".to_string(), toml::Value::Table(env_table)); } } let serialized_toml = toml::to_string_pretty(&toml_value) .map_err(|e| AcpError::protocol(format!("serialize codex toml failed: {e}")))?; if let Some(parent) = config_path.parent() { fs::create_dir_all(parent).map_err(|e| { AcpError::protocol(format!("create codex config directory failed: {e}")) })?; } fs::write(&config_path, format!("{serialized_toml}\n")) .map_err(|e| AcpError::protocol(format!("write codex config failed: {e}")))?; let auth_path = codex_auth_json_path(); let mut auth_value = if auth_path.exists() { match fs::read_to_string(&auth_path) .ok() .and_then(|raw| serde_json::from_str::(&raw).ok()) { Some(existing) if existing.is_object() => existing, _ => serde_json::json!({}), } } else { serde_json::json!({}) }; let auth_obj = auth_value .as_object_mut() .ok_or_else(|| AcpError::protocol("codex auth root must be object"))?; match trim_non_empty(api_key) { Some(api_key) => { auth_obj.insert( "OPENAI_API_KEY".to_string(), serde_json::Value::String(api_key), ); } None => { auth_obj.remove("OPENAI_API_KEY"); } } let serialized_auth = serde_json::to_string_pretty(&auth_value) .map_err(|e| AcpError::protocol(format!("serialize codex auth failed: {e}")))?; if let Some(parent) = auth_path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create codex auth directory failed: {e}")))?; } fs::write(&auth_path, format!("{serialized_auth}\n")) .map_err(|e| AcpError::protocol(format!("write codex auth failed: {e}")))?; Ok(()) } fn persist_codex_native_config_files( codex_auth_json: Option<&str>, codex_config_toml: Option<&str>, ) -> Result<(), AcpError> { if let Some(raw_toml) = codex_config_toml { toml::from_str::(raw_toml) .map_err(|e| AcpError::protocol(format!("invalid codex config.toml: {e}")))?; let path = codex_config_toml_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create codex directory failed: {e}")))?; } fs::write(&path, raw_toml) .map_err(|e| AcpError::protocol(format!("write codex config.toml failed: {e}")))?; } if let Some(raw_auth) = codex_auth_json { let parsed = serde_json::from_str::(raw_auth) .map_err(|e| AcpError::protocol(format!("invalid codex auth.json: {e}")))?; if !parsed.is_object() { return Err(AcpError::protocol( "invalid codex auth.json: root must be a JSON object", )); } let path = codex_auth_json_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create codex directory failed: {e}")))?; } fs::write(&path, raw_auth) .map_err(|e| AcpError::protocol(format!("write codex auth.json failed: {e}")))?; } Ok(()) } fn persist_opencode_auth_json(raw_auth: &str) -> Result<(), AcpError> { let parsed = serde_json::from_str::(raw_auth) .map_err(|e| AcpError::protocol(format!("invalid opencode auth.json: {e}")))?; if !parsed.is_object() { return Err(AcpError::protocol( "invalid opencode auth.json: root must be a JSON object", )); } let path = opencode_auth_json_path(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create opencode directory failed: {e}")))?; } fs::write(&path, format!("{raw_auth}\n")) .map_err(|e| AcpError::protocol(format!("write opencode auth.json failed: {e}")))?; Ok(()) } fn agent_local_config_path(agent_type: AgentType) -> Option { match agent_type { AgentType::ClaudeCode => Some(home_dir_or_default().join(".claude").join("settings.json")), AgentType::Gemini => Some(home_dir_or_default().join(".gemini").join("settings.json")), AgentType::OpenCode => Some(resolve_opencode_config_path()), _ => None, } } fn load_agent_local_config_json(agent_type: AgentType) -> Option { if agent_type == AgentType::Codex { return load_codex_local_config_json(); } let path = agent_local_config_path(agent_type)?; if !path.exists() { return None; } let raw = fs::read_to_string(path).ok()?; let parsed = serde_json::from_str::(&raw).ok()?; if !parsed.is_object() { return None; } serde_json::to_string_pretty(&parsed).ok() } fn merge_json_values(base: &mut serde_json::Value, patch: &serde_json::Value) { if let (Some(base_obj), Some(patch_obj)) = (base.as_object_mut(), patch.as_object()) { for (key, patch_value) in patch_obj { match base_obj.get_mut(key) { Some(base_value) => merge_json_values(base_value, patch_value), None => { base_obj.insert(key.clone(), patch_value.clone()); } } } return; } *base = patch.clone(); } fn persist_agent_local_config_json( agent_type: AgentType, config_patch_json: Option<&str>, ) -> Result<(), AcpError> { if agent_type == AgentType::Codex { return persist_codex_local_config(config_patch_json); } let Some(path) = agent_local_config_path(agent_type) else { return Ok(()); }; let Some(raw_patch) = config_patch_json else { return Ok(()); }; let patch = serde_json::from_str::(raw_patch) .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; if !patch.is_object() { return Err(AcpError::protocol( "invalid config_json: root must be a JSON object", )); } if agent_type == AgentType::OpenCode { let serialized = serde_json::to_string_pretty(&patch) .map_err(|e| AcpError::protocol(format!("serialize config_json failed: {e}")))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create config directory failed: {e}")))?; } fs::write(&path, format!("{serialized}\n")) .map_err(|e| AcpError::protocol(format!("write local config failed: {e}")))?; return Ok(()); } let mut base = if path.exists() { match fs::read_to_string(&path) .ok() .and_then(|raw| serde_json::from_str::(&raw).ok()) { Some(existing) if existing.is_object() => existing, _ => serde_json::json!({}), } } else { serde_json::json!({}) }; merge_json_values(&mut base, &patch); let serialized = serde_json::to_string_pretty(&base) .map_err(|e| AcpError::protocol(format!("serialize config_json failed: {e}")))?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|e| AcpError::protocol(format!("create config directory failed: {e}")))?; } fs::write(&path, format!("{serialized}\n")) .map_err(|e| AcpError::protocol(format!("write local config failed: {e}")))?; Ok(()) } fn skill_storage_spec(agent_type: AgentType) -> Option { match agent_type { AgentType::ClaudeCode => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOnly, global_dirs: vec![home_dir_or_default().join(".claude").join("skills")], project_rel_dirs: vec![".claude/skills"], }), AgentType::Codex => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOrMarkdownFile, global_dirs: vec![ home_dir_or_default().join(".agents").join("skills"), codex_home_dir().join("skills"), ], project_rel_dirs: vec![".agents/skills", ".codex/skills"], }), AgentType::OpenCode => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOnly, global_dirs: vec![home_dir_or_default() .join(".config") .join("opencode") .join("skills")], project_rel_dirs: vec![".opencode/skills"], }), AgentType::Gemini => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOnly, global_dirs: vec![ home_dir_or_default().join(".gemini").join("skills"), home_dir_or_default().join(".agents").join("skills"), ], project_rel_dirs: vec![".gemini/skills", ".agents/skills"], }), AgentType::OpenClaw => Some(SkillStorageSpec { kind: SkillStorageKind::SkillDirectoryOnly, global_dirs: vec![home_dir_or_default().join(".openclaw").join("skills")], project_rel_dirs: vec!["skills"], }), _ => None, } } fn scope_rank(scope: AgentSkillScope) -> u8 { match scope { AgentSkillScope::Global => 0, AgentSkillScope::Project => 1, } } fn validate_skill_id(raw: &str) -> Result { let id = raw.trim(); if id.is_empty() { return Err(AcpError::protocol("skill id cannot be empty")); } if id.starts_with('.') { return Err(AcpError::protocol("skill id cannot start with a dot (.)")); } if id.contains('/') || id.contains('\\') || id.contains("..") { return Err(AcpError::protocol( "skill id cannot contain path separators", )); } if !id .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') { return Err(AcpError::protocol( "skill id can only include letters, numbers, '-', '_' and '.'", )); } Ok(id.to_string()) } fn scoped_skill_dirs( agent_type: AgentType, scope: AgentSkillScope, workspace_path: Option<&str>, ) -> Result, AcpError> { let spec = skill_storage_spec(agent_type).ok_or_else(|| { AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" )) })?; match scope { AgentSkillScope::Global => Ok(spec.global_dirs), AgentSkillScope::Project => { let workspace = workspace_path .map(str::trim) .filter(|p| !p.is_empty()) .ok_or_else(|| { AcpError::protocol("workspace_path is required for project scoped skills") })?; Ok(spec .project_rel_dirs .iter() .map(|relative| PathBuf::from(workspace).join(relative)) .collect()) } } } fn preferred_scope_skill_dir( agent_type: AgentType, scope: AgentSkillScope, workspace_path: Option<&str>, ) -> Result { let dirs = scoped_skill_dirs(agent_type, scope, workspace_path)?; dirs.into_iter() .next() .ok_or_else(|| AcpError::protocol("no skill directory resolved for this agent")) } fn is_markdown_file(path: &Path) -> bool { path.extension() .and_then(|ext| ext.to_str()) .map(|ext| ext.eq_ignore_ascii_case("md")) .unwrap_or(false) } fn skill_name_from_id(id: &str) -> String { id.to_string() } fn build_skill_item( id: String, scope: AgentSkillScope, layout: AgentSkillLayout, path: PathBuf, ) -> AgentSkillItem { AgentSkillItem { name: skill_name_from_id(&id), id, scope, layout, path: path.to_string_lossy().to_string(), } } fn skill_content_path(layout: AgentSkillLayout, skill_path: &Path) -> PathBuf { match layout { AgentSkillLayout::SkillDirectory => skill_path.join("SKILL.md"), AgentSkillLayout::MarkdownFile => skill_path.to_path_buf(), } } fn list_skills_from_dir( scope: AgentSkillScope, dir: &Path, kind: SkillStorageKind, ) -> Result, AcpError> { if !dir.exists() { return Ok(Vec::new()); } let entries = fs::read_dir(dir) .map_err(|e| AcpError::protocol(format!("failed to read skills directory: {e}")))?; let mut by_id: BTreeMap = BTreeMap::new(); for entry in entries { let entry = match entry { Ok(value) => value, Err(_) => continue, }; let path = entry.path(); let file_name = entry.file_name(); let id = file_name.to_string_lossy().to_string(); if path.is_dir() && matches!( kind, SkillStorageKind::SkillDirectoryOnly | SkillStorageKind::SkillDirectoryOrMarkdownFile ) { let skill_doc = path.join("SKILL.md"); if !skill_doc.is_file() { continue; } by_id.insert( id.clone(), build_skill_item(id, scope.clone(), AgentSkillLayout::SkillDirectory, path), ); continue; } if path.is_file() && matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) && is_markdown_file(&path) { let stem = path .file_stem() .and_then(|s| s.to_str()) .map(str::to_string) .unwrap_or_else(|| id.clone()); if by_id.contains_key(&stem) { continue; } by_id.insert( stem.clone(), build_skill_item(stem, scope.clone(), AgentSkillLayout::MarkdownFile, path), ); } } Ok(by_id.into_values().collect()) } fn locate_existing_skill( dir: &Path, kind: SkillStorageKind, skill_id: &str, scope: AgentSkillScope, ) -> Option { if matches!( kind, SkillStorageKind::SkillDirectoryOnly | SkillStorageKind::SkillDirectoryOrMarkdownFile ) { let skill_dir = dir.join(skill_id); if skill_dir.is_dir() && skill_dir.join("SKILL.md").is_file() { return Some(build_skill_item( skill_id.to_string(), scope.clone(), AgentSkillLayout::SkillDirectory, skill_dir, )); } } if matches!(kind, SkillStorageKind::SkillDirectoryOrMarkdownFile) { let file_path = dir.join(format!("{skill_id}.md")); if file_path.is_file() { return Some(build_skill_item( skill_id.to_string(), scope, AgentSkillLayout::MarkdownFile, file_path, )); } } None } fn locate_existing_skill_across_dirs( dirs: &[PathBuf], kind: SkillStorageKind, skill_id: &str, scope: AgentSkillScope, ) -> Option { for dir in dirs { if let Some(found) = locate_existing_skill(dir, kind, skill_id, scope) { return Some(found); } } None } #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] struct AgentRuntimeConfig { #[serde(default, alias = "api_base_url")] api_base_url: Option, #[serde(default, alias = "api_key")] api_key: Option, #[serde(default)] model: Option, #[serde(default)] env: BTreeMap, } fn trim_non_empty(value: Option) -> Option { value.and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }) } fn important_env_targets(agent_type: AgentType) -> (&'static str, &'static str, &'static str) { match agent_type { AgentType::ClaudeCode => ("ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_MODEL"), AgentType::Gemini => ("GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY", "GEMINI_MODEL"), _ => ("OPENAI_BASE_URL", "OPENAI_API_KEY", "OPENAI_MODEL"), } } fn build_runtime_env_from_setting( agent_type: AgentType, setting: Option<&crate::db::entities::agent_setting::Model>, local_config_json: Option<&str>, ) -> BTreeMap { let mut merged = setting .and_then(|model| model.env_json.as_deref()) .and_then(|raw| serde_json::from_str::>(raw).ok()) .unwrap_or_default(); let Some(raw_config_json) = local_config_json else { return merged; }; let Ok(config) = serde_json::from_str::(raw_config_json) else { return merged; }; for (key, value) in config.env { let trimmed = value.trim(); if trimmed.is_empty() { continue; } merged.insert(key, trimmed.to_string()); } let (api_base_url_key, api_key_key, model_key) = important_env_targets(agent_type); if let Some(value) = trim_non_empty(config.api_base_url) { merged.insert(api_base_url_key.to_string(), value); } if let Some(value) = trim_non_empty(config.api_key) { merged.insert(api_key_key.to_string(), value); } if agent_type != AgentType::ClaudeCode { if let Some(value) = trim_non_empty(config.model) { merged.insert(model_key.to_string(), value); } } merged } #[tauri::command] pub async fn acp_preflight(agent_type: AgentType) -> Result { Ok(preflight::run_preflight(agent_type).await) } #[tauri::command] pub async fn acp_connect( agent_type: AgentType, working_dir: Option, session_id: Option, manager: State<'_, ConnectionManager>, db: State<'_, AppDatabase>, app_handle: tauri::AppHandle, window: tauri::WebviewWindow, ) -> Result { let meta = registry::get_agent_meta(agent_type); let setting = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let disabled = setting .as_ref() .map(|model| !model.enabled) .unwrap_or(false); if disabled { return Err(AcpError::protocol(format!( "{agent_type} is disabled in settings" ))); } let local_config_json = load_agent_local_config_json(agent_type); let runtime_env = build_runtime_env_from_setting(agent_type, setting.as_ref(), local_config_json.as_deref()); if let registry::AgentDistribution::Npx { package, .. } = meta.distribution { if detect_npx_cached_version(package).await.is_none() { prepare_npx_package(package).await?; } else { ensure_npx_cached_bins_executable(package).await?; } } manager .spawn_agent( agent_type, working_dir, session_id, runtime_env, window.label().to_string(), app_handle, ) .await } #[tauri::command] pub async fn acp_prompt( connection_id: String, blocks: Vec, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager.send_prompt(&connection_id, blocks).await } #[tauri::command] pub async fn acp_set_mode( connection_id: String, mode_id: String, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager.set_mode(&connection_id, mode_id).await } #[tauri::command] pub async fn acp_set_config_option( connection_id: String, config_id: String, value_id: String, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager .set_config_option(&connection_id, config_id, value_id) .await } #[tauri::command] pub async fn acp_cancel( connection_id: String, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager.cancel(&connection_id).await } #[tauri::command] pub async fn acp_respond_permission( connection_id: String, request_id: String, option_id: String, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager .respond_permission(&connection_id, &request_id, &option_id) .await } #[tauri::command] pub async fn acp_disconnect( connection_id: String, manager: State<'_, ConnectionManager>, ) -> Result<(), AcpError> { manager.disconnect(&connection_id).await } #[tauri::command] pub async fn acp_list_connections( manager: State<'_, ConnectionManager>, ) -> Result, AcpError> { Ok(manager.list_connections().await) } #[tauri::command] pub async fn acp_list_agents( db: tauri::State<'_, AppDatabase>, ) -> Result, AcpError> { let platform = registry::current_platform(); let agent_types = registry::all_acp_agents(); let defaults = agent_types .iter() .enumerate() .map( |(idx, agent_type)| agent_setting_service::AgentDefaultInput { agent_type: *agent_type, registry_id: registry::registry_id_for(*agent_type).to_string(), default_sort_order: idx as i32, }, ) .collect::>(); agent_setting_service::ensure_defaults(&db.conn, &defaults) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let settings_map = agent_setting_service::list_map_by_agent_type(&db.conn) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let mut agents = Vec::new(); for (idx, agent_type) in agent_types.into_iter().enumerate() { let setting = settings_map.get(&agent_type); let meta = registry::get_agent_meta(agent_type); let (available, dist_type, local_installed_version) = match &meta.distribution { registry::AgentDistribution::Npx { .. } => ( true, "npx", setting.and_then(|m| m.installed_version.clone()), ), registry::AgentDistribution::Uvx { .. } => ( true, "uvx", setting.and_then(|m| m.installed_version.clone()), ), registry::AgentDistribution::Binary { platforms, cmd, .. } => { let detected = binary_cache::detect_installed_version(agent_type, cmd) .ok() .flatten(); ( platforms.iter().any(|p| p.platform == platform), "binary", detected, ) } }; let mut env = setting .and_then(|m| m.env_json.as_deref()) .and_then(|s| serde_json::from_str::>(s).ok()) .unwrap_or_default(); let local_config_json = load_agent_local_config_json(agent_type); if let Some(raw_local_config) = local_config_json.as_deref() { if let Ok(local_cfg) = serde_json::from_str::(raw_local_config) { for (key, value) in local_cfg.env { let trimmed = value.trim(); if trimmed.is_empty() { continue; } env.insert(key, trimmed.to_string()); } let (api_base_url_key, api_key_key, model_key) = important_env_targets(agent_type); if let Some(value) = trim_non_empty(local_cfg.api_base_url) { env.insert(api_base_url_key.to_string(), value); } if let Some(value) = trim_non_empty(local_cfg.api_key) { env.insert(api_key_key.to_string(), value); } if agent_type != AgentType::ClaudeCode { if let Some(value) = trim_non_empty(local_cfg.model) { env.insert(model_key.to_string(), value); } } } } let sort_order = setting.map(|m| m.sort_order).unwrap_or(idx as i32); if dist_type == "binary" { let _ = agent_setting_service::set_installed_version( &db.conn, agent_type, local_installed_version.clone(), ) .await; } let codex_auth_json = if agent_type == AgentType::Codex { load_codex_auth_json_raw() } else { None }; let opencode_auth_json = if agent_type == AgentType::OpenCode { load_opencode_auth_json_raw() } else { None }; let codex_config_toml = if agent_type == AgentType::Codex { load_codex_config_toml_raw() } else { None }; agents.push(AcpAgentInfo { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), registry_version: meta.registry_version().map(ToString::to_string), name: meta.name.to_string(), description: meta.description.to_string(), available, distribution_type: dist_type.to_string(), enabled: setting.map(|m| m.enabled).unwrap_or(true), sort_order, installed_version: local_installed_version, env, config_json: local_config_json, config_file_path: agent_local_config_path(agent_type) .map(|path| path.display().to_string()), opencode_auth_json, codex_auth_json, codex_config_toml, }); } agents.sort_by(|a, b| { a.sort_order .cmp(&b.sort_order) .then_with(|| a.name.cmp(&b.name)) }); Ok(agents) } #[tauri::command] pub async fn acp_clear_binary_cache(agent_type: AgentType) -> Result<(), AcpError> { let meta = registry::get_agent_meta(agent_type); if matches!( meta.distribution, registry::AgentDistribution::Binary { .. } ) { binary_cache::clear_agent_cache(agent_type)?; } Ok(()) } #[tauri::command] pub async fn acp_update_agent_preferences( agent_type: AgentType, enabled: bool, env: BTreeMap, config_json: Option, opencode_auth_json: Option, codex_auth_json: Option, codex_config_toml: Option, db: State<'_, AppDatabase>, ) -> Result<(), AcpError> { let default = agent_setting_service::AgentDefaultInput { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), default_sort_order: i32::MAX / 2, }; agent_setting_service::ensure_defaults(&db.conn, &[default]) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let env_json = if env.is_empty() { None } else { Some(serde_json::to_string(&env).map_err(|e| AcpError::protocol(e.to_string()))?) }; let config_json = config_json.and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); let opencode_auth_json = opencode_auth_json.and_then(|raw| { let trimmed = raw.trim(); if trimmed.is_empty() { None } else { Some(trimmed.to_string()) } }); if let Some(raw) = config_json.as_deref() { let parsed = serde_json::from_str::(raw) .map_err(|e| AcpError::protocol(format!("invalid config_json: {e}")))?; if !parsed.is_object() { return Err(AcpError::protocol( "invalid config_json: root must be a JSON object", )); } } let patch = agent_setting_service::AgentSettingsUpdate { enabled, env_json }; agent_setting_service::update(&db.conn, agent_type, patch) .await .map_err(|e| AcpError::protocol(e.to_string()))?; if agent_type == AgentType::Codex { if codex_auth_json.is_some() || codex_config_toml.is_some() { persist_codex_native_config_files( codex_auth_json.as_deref(), codex_config_toml.as_deref(), )?; } return Ok(()); } if agent_type == AgentType::OpenCode { if let Some(raw_auth) = opencode_auth_json.as_deref() { persist_opencode_auth_json(raw_auth)?; } if let Some(raw) = config_json.as_deref() { persist_agent_local_config_json(agent_type, Some(raw))?; } return Ok(()); } let mut local_patch_value = config_json .as_deref() .and_then(|raw| serde_json::from_str::(raw).ok()) .filter(|value| value.is_object()) .unwrap_or_else(|| serde_json::json!({})); if !env.is_empty() { let env_json_value = serde_json::to_value(&env).map_err(|e| AcpError::protocol(e.to_string()))?; if let Some(obj) = local_patch_value.as_object_mut() { obj.insert("env".to_string(), env_json_value); } } let local_patch_json = serde_json::to_string(&local_patch_value) .map_err(|e| AcpError::protocol(format!("serialize local patch failed: {e}")))?; persist_agent_local_config_json(agent_type, Some(local_patch_json.as_str()))?; Ok(()) } #[tauri::command] pub async fn acp_download_agent_binary(agent_type: AgentType) -> Result<(), AcpError> { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Binary { version, cmd, platforms, .. } => { let platform = registry::current_platform(); let fallback = platforms .iter() .find(|p| p.platform == platform) .ok_or_else(|| { AcpError::PlatformNotSupported(format!( "{} is not available on {platform}", meta.name )) })?; let _ = binary_cache::ensure_binary_for_agent(agent_type, version, fallback.url, cmd) .await?; Ok(()) } registry::AgentDistribution::Npx { .. } | registry::AgentDistribution::Uvx { .. } => Err( AcpError::protocol("download is only supported for binary agents"), ), } } #[tauri::command] pub async fn acp_detect_agent_local_version( agent_type: AgentType, db: State<'_, AppDatabase>, ) -> Result, AcpError> { let detected = detect_local_version(agent_type).await; if let Some(version) = detected.clone() { let _ = agent_setting_service::set_installed_version( &db.conn, agent_type, Some(version.clone()), ) .await; return Ok(Some(version)); } // For package-based agents, probing can miss cached availability. // Fall back to last known installed version persisted in DB. let fallback = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .ok() .flatten() .and_then(|m| m.installed_version); Ok(fallback) } #[tauri::command] pub async fn acp_prepare_npx_agent( agent_type: AgentType, registry_version: Option, db: State<'_, AppDatabase>, ) -> Result { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Npx { package, .. } => { let default = agent_setting_service::AgentDefaultInput { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), default_sort_order: i32::MAX / 2, }; agent_setting_service::ensure_defaults(&db.conn, &[default]) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let existing = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .ok() .flatten() .and_then(|m| m.installed_version); prepare_npx_package(package).await?; let resolved = detect_local_version(agent_type) .await .or_else(|| version_from_package_spec(package)) .or_else(|| { registry_version .as_deref() .and_then(normalize_version_candidate) }) .or(existing) .ok_or_else(|| { AcpError::protocol( "npx install succeeded but failed to determine local version", ) })?; agent_setting_service::set_installed_version( &db.conn, agent_type, Some(resolved.clone()), ) .await .map_err(|e| AcpError::protocol(e.to_string()))?; Ok(resolved) } registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( "prepare is only supported for npx agents", )), registry::AgentDistribution::Uvx { .. } => Err(AcpError::protocol( "prepare is only supported for npx agents", )), } } #[tauri::command] pub async fn acp_prepare_uvx_agent( agent_type: AgentType, registry_version: Option, db: State<'_, AppDatabase>, ) -> Result { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Uvx { package, .. } => { let default = agent_setting_service::AgentDefaultInput { agent_type, registry_id: registry::registry_id_for(agent_type).to_string(), default_sort_order: i32::MAX / 2, }; agent_setting_service::ensure_defaults(&db.conn, &[default]) .await .map_err(|e| AcpError::protocol(e.to_string()))?; let existing = agent_setting_service::get_by_agent_type(&db.conn, agent_type) .await .ok() .flatten() .and_then(|m| m.installed_version); prepare_uvx_package(package).await?; let resolved = detect_local_version(agent_type) .await .or_else(|| version_from_package_spec(package)) .or_else(|| { registry_version .as_deref() .and_then(normalize_version_candidate) }) .or(existing) .ok_or_else(|| { AcpError::protocol( "uvx install succeeded but failed to determine local version", ) })?; agent_setting_service::set_installed_version( &db.conn, agent_type, Some(resolved.clone()), ) .await .map_err(|e| AcpError::protocol(e.to_string()))?; Ok(resolved) } registry::AgentDistribution::Npx { .. } => Err(AcpError::protocol( "prepare is only supported for uvx agents", )), registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol( "prepare is only supported for uvx agents", )), } } #[tauri::command] pub async fn acp_uninstall_agent( agent_type: AgentType, db: State<'_, AppDatabase>, ) -> Result<(), AcpError> { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Binary { .. } => { binary_cache::clear_agent_cache(agent_type)?; } registry::AgentDistribution::Npx { package, .. } => { uninstall_npx_package(package).await?; } registry::AgentDistribution::Uvx { package, .. } => { uninstall_uvx_package(package).await?; } } agent_setting_service::set_installed_version(&db.conn, agent_type, None) .await .map_err(|e| AcpError::protocol(e.to_string()))?; Ok(()) } #[tauri::command] pub async fn acp_reorder_agents( agent_types: Vec, db: State<'_, AppDatabase>, ) -> Result<(), AcpError> { if agent_types.is_empty() { return Ok(()); } agent_setting_service::reorder(&db.conn, &agent_types) .await .map_err(|e| { let message = e.to_string(); if message.contains("database or disk is full") || message.contains("(code: 13)") { AcpError::protocol("无法保存排序:数据库可写空间不足。请释放磁盘空间后重试。") } else { AcpError::protocol(message) } })?; Ok(()) } #[tauri::command] pub async fn acp_list_agent_skills( agent_type: AgentType, workspace_path: Option, ) -> Result { let Some(spec) = skill_storage_spec(agent_type) else { return Ok(AgentSkillsListResult { supported: false, message: Some(format!( "{agent_type} 暂不支持在设置页管理 Skills(当前仅支持 Claude Code / Codex / OpenCode / Gemini CLI / OpenClaw)" )), locations: Vec::new(), skills: Vec::new(), }); }; let mut locations = Vec::new(); let mut skills_by_key: BTreeMap = BTreeMap::new(); for dir in &spec.global_dirs { locations.push(AgentSkillLocation { scope: AgentSkillScope::Global, path: dir.to_string_lossy().to_string(), exists: dir.exists(), }); let listed = list_skills_from_dir(AgentSkillScope::Global, dir, spec.kind)?; for skill in listed { let key = format!("global:{}", skill.id); skills_by_key.entry(key).or_insert(skill); } } if let Some(workspace) = workspace_path.as_deref().map(str::trim) { if !workspace.is_empty() { for relative in &spec.project_rel_dirs { let project_dir = PathBuf::from(workspace).join(relative); locations.push(AgentSkillLocation { scope: AgentSkillScope::Project, path: project_dir.to_string_lossy().to_string(), exists: project_dir.exists(), }); let listed = list_skills_from_dir(AgentSkillScope::Project, &project_dir, spec.kind)?; for skill in listed { let key = format!("project:{}", skill.id); skills_by_key.entry(key).or_insert(skill); } } } } let mut skills = skills_by_key.into_values().collect::>(); skills.sort_by(|a, b| { scope_rank(a.scope.clone()) .cmp(&scope_rank(b.scope.clone())) .then_with(|| a.name.cmp(&b.name)) }); Ok(AgentSkillsListResult { supported: true, message: None, locations, skills, }) } #[tauri::command] pub async fn acp_read_agent_skill( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, workspace_path: Option, ) -> Result { let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" ))); }; let id = validate_skill_id(&skill_id)?; let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; let content_path = skill_content_path(skill.layout.clone(), Path::new(&skill.path)); let content = fs::read_to_string(&content_path) .map_err(|e| AcpError::protocol(format!("failed to read skill content: {e}")))?; Ok(AgentSkillContent { skill, content }) } #[tauri::command] pub async fn acp_save_agent_skill( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, content: String, workspace_path: Option, layout: Option, ) -> Result { let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" ))); }; let id = validate_skill_id(&skill_id)?; let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; let preferred_dir = preferred_scope_skill_dir(agent_type, scope, workspace_path.as_deref())?; fs::create_dir_all(&preferred_dir) .map_err(|e| AcpError::protocol(format!("failed to create skills directory: {e}")))?; let existing = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope); let skill = if let Some(item) = existing { item } else { let new_layout = match spec.kind { SkillStorageKind::SkillDirectoryOnly => AgentSkillLayout::SkillDirectory, SkillStorageKind::SkillDirectoryOrMarkdownFile => { layout.unwrap_or(AgentSkillLayout::MarkdownFile) } }; let skill_path = match new_layout { AgentSkillLayout::SkillDirectory => preferred_dir.join(&id), AgentSkillLayout::MarkdownFile => preferred_dir.join(format!("{id}.md")), }; build_skill_item(id.clone(), scope, new_layout, skill_path) }; let skill_path = PathBuf::from(&skill.path); let content_path = skill_content_path(skill.layout.clone(), &skill_path); if skill.layout == AgentSkillLayout::SkillDirectory { fs::create_dir_all(&skill_path).map_err(|e| { AcpError::protocol(format!( "failed to create skill directory '{}': {e}", skill.path )) })?; } else if let Some(parent) = content_path.parent() { fs::create_dir_all(parent).map_err(|e| { AcpError::protocol(format!("failed to create skill parent directory: {e}")) })?; } fs::write(&content_path, content) .map_err(|e| AcpError::protocol(format!("failed to write skill content: {e}")))?; Ok(skill) } #[tauri::command] pub async fn acp_delete_agent_skill( agent_type: AgentType, scope: AgentSkillScope, skill_id: String, workspace_path: Option, ) -> Result<(), AcpError> { let Some(spec) = skill_storage_spec(agent_type) else { return Err(AcpError::protocol(format!( "{agent_type} skills are not supported in Settings yet" ))); }; let id = validate_skill_id(&skill_id)?; let dirs = scoped_skill_dirs(agent_type, scope, workspace_path.as_deref())?; let skill = locate_existing_skill_across_dirs(&dirs, spec.kind, &id, scope) .ok_or_else(|| AcpError::protocol(format!("skill not found: {id}")))?; let skill_path = PathBuf::from(&skill.path); if skill.layout == AgentSkillLayout::SkillDirectory { fs::remove_dir_all(&skill_path) .map_err(|e| AcpError::protocol(format!("failed to delete skill directory: {e}")))?; } else { fs::remove_file(&skill_path) .map_err(|e| AcpError::protocol(format!("failed to delete skill file: {e}")))?; } Ok(()) }