Files
codeg/src-tauri/src/commands/acp.rs
2026-03-17 23:24:07 +08:00

1750 lines
57 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tauri::{Emitter, 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, ForkResultInfo, PromptInputBlock,
};
use crate::db::service::agent_setting_service;
use crate::db::AppDatabase;
use crate::models::agent::AgentType;
const ACP_AGENTS_UPDATED_EVENT: &str = "app://acp-agents-updated";
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
struct AcpAgentsUpdatedEventPayload {
reason: &'static str,
agent_type: Option<AgentType>,
}
fn emit_acp_agents_updated(
app: &tauri::AppHandle,
reason: &'static str,
agent_type: Option<AgentType>,
) {
let _ = app.emit(
ACP_AGENTS_UPDATED_EVENT,
AcpAgentsUpdatedEventPayload { reason, agent_type },
);
}
fn is_version_like(value: &str) -> bool {
value.chars().any(|c| c.is_ascii_digit()) && value.contains('.')
}
fn normalize_version_candidate(value: &str) -> Option<String> {
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<String> {
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()
}
async fn detect_global_cmd_version(cmd: &str) -> Option<String> {
let output = crate::process::tokio_command(cmd)
.arg("--version")
.output()
.await
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout).trim().to_string();
normalize_version_candidate(&raw)
}
async fn detect_local_version(agent_type: AgentType) -> Option<String> {
let meta = registry::get_agent_meta(agent_type);
match meta.distribution {
registry::AgentDistribution::Npx { cmd, .. } => {
detect_global_cmd_version(cmd).await
}
registry::AgentDistribution::Binary { cmd, .. } => {
binary_cache::detect_installed_version(agent_type, cmd)
.ok()
.flatten()
}
}
}
async fn install_npm_global_package(package: &str) -> Result<(), AcpError> {
let output = crate::process::tokio_command("npm")
.arg("install")
.arg("-g")
.arg(package)
.output()
.await
.map_err(|e| AcpError::protocol(format!("failed to run npm install -g: {e}")))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
let msg = if err.is_empty() {
"failed to install npm package globally".to_string()
} else {
format!("failed to install npm package globally: {err}")
};
return Err(AcpError::protocol(msg));
}
Ok(())
}
async fn uninstall_npm_global_package(package: &str) -> Result<(), AcpError> {
let package_name = package_name_from_spec(package);
if !package_name.is_empty() {
let output = crate::process::tokio_command("npm")
.arg("uninstall")
.arg("-g")
.arg(&package_name)
.output()
.await
.map_err(|e| AcpError::protocol(format!("failed to run npm uninstall -g: {e}")))?;
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
let msg = if err.is_empty() {
"failed to uninstall npm package globally".to_string()
} else {
format!("failed to uninstall npm package globally: {err}")
};
return Err(AcpError::protocol(msg));
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkillStorageKind {
SkillDirectoryOnly,
SkillDirectoryOrMarkdownFile,
}
#[derive(Debug, Clone)]
struct SkillStorageSpec {
kind: SkillStorageKind,
global_dirs: Vec<PathBuf>,
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<String> {
fs::read_to_string(opencode_auth_json_path()).ok()
}
fn load_codex_auth_json_raw() -> Option<String> {
fs::read_to_string(codex_auth_json_path()).ok()
}
fn load_codex_config_toml_raw() -> Option<String> {
fs::read_to_string(codex_config_toml_path()).ok()
}
fn load_codex_local_config_json() -> Option<String> {
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::<toml::Value>() {
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<String> = 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::<serde_json::Value>(&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::<AgentRuntimeConfig>(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::<toml::Value>().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::<serde_json::Value>(&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::<toml::Table>(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::<serde_json::Value>(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::<serde_json::Value>(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<PathBuf> {
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<String> {
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::<serde_json::Value>(&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::<serde_json::Value>(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::<serde_json::Value>(&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<SkillStorageSpec> {
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"],
}),
}
}
fn scope_rank(scope: AgentSkillScope) -> u8 {
match scope {
AgentSkillScope::Global => 0,
AgentSkillScope::Project => 1,
}
}
fn validate_skill_id(raw: &str) -> Result<String, AcpError> {
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<Vec<PathBuf>, 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<PathBuf, AcpError> {
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<Vec<AgentSkillItem>, 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<String, AgentSkillItem> = 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, 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, AgentSkillLayout::MarkdownFile, path),
);
}
}
Ok(by_id.into_values().collect())
}
fn locate_existing_skill(
dir: &Path,
kind: SkillStorageKind,
skill_id: &str,
scope: AgentSkillScope,
) -> Option<AgentSkillItem> {
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,
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<AgentSkillItem> {
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<String>,
#[serde(default, alias = "api_key")]
api_key: Option<String>,
#[serde(default)]
model: Option<String>,
#[serde(default)]
env: BTreeMap<String, String>,
}
fn trim_non_empty(value: Option<String>) -> Option<String> {
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<String, String> {
let mut merged = setting
.and_then(|model| model.env_json.as_deref())
.and_then(|raw| serde_json::from_str::<BTreeMap<String, String>>(raw).ok())
.unwrap_or_default();
let Some(raw_config_json) = local_config_json else {
return merged;
};
let Ok(config) = serde_json::from_str::<AgentRuntimeConfig>(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,
force_refresh: Option<bool>,
) -> Result<PreflightResult, AcpError> {
if force_refresh.unwrap_or(false) {
preflight::clear_npm_env_cache();
}
Ok(preflight::run_preflight(agent_type).await)
}
#[tauri::command]
pub async fn acp_connect(
agent_type: AgentType,
working_dir: Option<String>,
session_id: Option<String>,
manager: State<'_, ConnectionManager>,
db: State<'_, AppDatabase>,
app_handle: tauri::AppHandle,
window: tauri::WebviewWindow,
) -> Result<String, AcpError> {
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 mut runtime_env =
build_runtime_env_from_setting(agent_type, setting.as_ref(), local_config_json.as_deref());
// For OpenClaw: when creating a new conversation (no session_id to resume),
// signal that we want a fresh transcript via --reset-session.
if agent_type == AgentType::OpenClaw && session_id.is_none() {
runtime_env.insert("OPENCLAW_RESET_SESSION".into(), "1".into());
}
if let registry::AgentDistribution::Npx { cmd, package, .. } = meta.distribution {
if detect_global_cmd_version(cmd).await.is_none() {
install_npm_global_package(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<PromptInputBlock>,
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_fork(
connection_id: String,
manager: State<'_, ConnectionManager>,
) -> Result<ForkResultInfo, AcpError> {
manager.fork_session(&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<Vec<ConnectionInfo>, AcpError> {
Ok(manager.list_connections().await)
}
#[tauri::command]
pub async fn acp_get_agent_status(
agent_type: AgentType,
db: tauri::State<'_, AppDatabase>,
) -> Result<crate::acp::types::AcpAgentStatus, AcpError> {
let platform = registry::current_platform();
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 (available, installed_version) = match &meta.distribution {
registry::AgentDistribution::Npx { .. } => (
true,
setting.as_ref().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),
detected,
)
}
};
Ok(crate::acp::types::AcpAgentStatus {
agent_type,
available,
enabled: setting.map(|m| m.enabled).unwrap_or(true),
installed_version,
})
}
#[tauri::command]
pub async fn acp_list_agents(
db: tauri::State<'_, AppDatabase>,
) -> Result<Vec<AcpAgentInfo>, 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::<Vec<_>>();
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::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::<BTreeMap<String, String>>(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::<AgentRuntimeConfig>(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]
#[allow(clippy::too_many_arguments)]
pub async fn acp_update_agent_preferences(
agent_type: AgentType,
enabled: bool,
env: BTreeMap<String, String>,
config_json: Option<String>,
opencode_auth_json: Option<String>,
codex_auth_json: Option<String>,
codex_config_toml: Option<String>,
db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> 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::<serde_json::Value>(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(),
)?;
}
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
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))?;
}
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
return Ok(());
}
let mut local_patch_value = config_json
.as_deref()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(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()))?;
emit_acp_agents_updated(&app, "preferences_updated", Some(agent_type));
Ok(())
}
#[tauri::command]
pub async fn acp_download_agent_binary(
agent_type: AgentType,
app: tauri::AppHandle,
) -> 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?;
emit_acp_agents_updated(&app, "binary_downloaded", Some(agent_type));
Ok(())
}
registry::AgentDistribution::Npx { .. } => 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<Option<String>, 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<String>,
db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> Result<String, AcpError> {
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);
install_npm_global_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(
"npm global 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()))?;
emit_acp_agents_updated(&app, "npx_prepared", Some(agent_type));
Ok(resolved)
}
registry::AgentDistribution::Binary { .. } => Err(AcpError::protocol(
"prepare is only supported for npx agents",
)),
}
}
#[tauri::command]
pub async fn acp_uninstall_agent(
agent_type: AgentType,
db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> 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_npm_global_package(package).await?;
}
}
agent_setting_service::set_installed_version(&db.conn, agent_type, None)
.await
.map_err(|e| AcpError::protocol(e.to_string()))?;
emit_acp_agents_updated(&app, "agent_uninstalled", Some(agent_type));
Ok(())
}
#[tauri::command]
pub async fn acp_reorder_agents(
agent_types: Vec<AgentType>,
db: State<'_, AppDatabase>,
app: tauri::AppHandle,
) -> 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)
}
})?;
emit_acp_agents_updated(&app, "agent_reordered", None);
Ok(())
}
#[tauri::command]
pub async fn acp_list_agent_skills(
agent_type: AgentType,
workspace_path: Option<String>,
) -> Result<AgentSkillsListResult, AcpError> {
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<String, AgentSkillItem> = 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::<Vec<_>>();
skills.sort_by(|a, b| {
scope_rank(a.scope)
.cmp(&scope_rank(b.scope))
.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<String>,
) -> Result<AgentSkillContent, 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 content_path = skill_content_path(skill.layout, 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<String>,
layout: Option<AgentSkillLayout>,
) -> Result<AgentSkillItem, 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 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, &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<String>,
) -> 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(())
}