diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs new file mode 100644 index 000000000..d6a9286d1 --- /dev/null +++ b/src-tauri/src/commands/hermes.rs @@ -0,0 +1,94 @@ +use tauri::State; + +use crate::hermes_config; +use crate::store::AppState; + +// ============================================================================ +// Hermes Provider Commands +// ============================================================================ + +/// Import providers from Hermes live config to database. +/// +/// Hermes uses additive mode — users may already have providers +/// configured in config.yaml. +#[tauri::command] +pub fn import_hermes_providers_from_live(state: State<'_, AppState>) -> Result { + crate::services::provider::import_hermes_providers_from_live(state.inner()) + .map_err(|e| e.to_string()) +} + +/// Get provider names in the Hermes live config. +#[tauri::command] +pub fn get_hermes_live_provider_ids() -> Result, String> { + hermes_config::get_providers() + .map(|providers| providers.keys().cloned().collect()) + .map_err(|e| e.to_string()) +} + +/// Get a single Hermes provider fragment from live config. +#[tauri::command] +pub fn get_hermes_live_provider( + #[allow(non_snake_case)] providerId: String, +) -> Result, String> { + hermes_config::get_provider(&providerId).map_err(|e| e.to_string()) +} + +/// Scan config.yaml for known configuration hazards. +#[tauri::command] +pub fn scan_hermes_config_health() -> Result, String> { + hermes_config::scan_hermes_config_health().map_err(|e| e.to_string()) +} + +// ============================================================================ +// Model Configuration Commands +// ============================================================================ + +/// Get Hermes model config (model section of config.yaml) +#[tauri::command] +pub fn get_hermes_model_config() -> Result, String> { + hermes_config::get_model_config().map_err(|e| e.to_string()) +} + +/// Set Hermes model config (model section of config.yaml) +#[tauri::command] +pub fn set_hermes_model_config( + model: hermes_config::HermesModelConfig, +) -> Result { + hermes_config::set_model_config(&model).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Agent Configuration Commands +// ============================================================================ + +/// Get Hermes agent config (agent section of config.yaml) +#[tauri::command] +pub fn get_hermes_agent_config() -> Result, String> { + hermes_config::get_agent_config().map_err(|e| e.to_string()) +} + +/// Set Hermes agent config (agent section of config.yaml) +#[tauri::command] +pub fn set_hermes_agent_config( + agent: hermes_config::HermesAgentConfig, +) -> Result { + hermes_config::set_agent_config(&agent).map_err(|e| e.to_string()) +} + +// ============================================================================ +// Env Configuration Commands +// ============================================================================ + +/// Get Hermes env config (.env file) +#[tauri::command] +pub fn get_hermes_env() -> Result { + hermes_config::read_env().map_err(|e| e.to_string()) +} + +/// Set Hermes env config (.env file) +#[tauri::command] +pub fn set_hermes_env( + env: hermes_config::HermesEnvConfig, +) -> Result { + hermes_config::write_env(&env).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 6bf8a8c73..6b8aff027 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -10,6 +10,7 @@ mod deeplink; mod env; mod failover; mod global_proxy; +mod hermes; mod import_export; mod mcp; mod misc; @@ -42,6 +43,7 @@ pub use deeplink::*; pub use env::*; pub use failover::*; pub use global_proxy::*; +pub use hermes::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs index 74c926d24..4957ddf8d 100644 --- a/src-tauri/src/hermes_config.rs +++ b/src-tauri/src/hermes_config.rs @@ -2,9 +2,39 @@ //! //! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。 //! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 +//! +//! ## 配置结构示例 +//! +//! ```yaml +//! model: +//! default: "anthropic/claude-opus-4-6" +//! provider: "openrouter" +//! base_url: "https://openrouter.ai/api/v1" +//! +//! agent: +//! max_turns: 50 +//! reasoning_effort: "high" +//! +//! custom_providers: +//! - name: openrouter +//! base_url: https://openrouter.ai/api/v1 +//! api_key: sk-or-... +//! +//! mcp_servers: +//! filesystem: +//! command: npx +//! args: ["-y", "@modelcontextprotocol/server-filesystem"] +//! ``` -use crate::settings::get_hermes_override_dir; -use std::path::PathBuf; +use crate::config::{atomic_write, get_app_config_dir}; +use crate::error::AppError; +use crate::settings::{effective_backup_retain_count, get_hermes_override_dir}; +use chrono::Local; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; // ============================================================================ // Path Functions @@ -29,3 +59,1150 @@ pub fn get_hermes_config_path() -> PathBuf { get_hermes_dir().join("config.yaml") } +fn hermes_write_lock() -> &'static Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) +} + +// ============================================================================ +// Type Definitions +// ============================================================================ + +/// Hermes 健康检查警告 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HermesHealthWarning { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// Hermes 写入结果 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct HermesWriteOutcome { + #[serde(skip_serializing_if = "Option::is_none")] + pub backup_path: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub warnings: Vec, +} + +/// Hermes model section config +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesModelConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + /// Preserve unknown fields for forward compatibility + #[serde(flatten)] + pub extra: HashMap, +} + +/// Hermes agent section config (agent + approvals) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesAgentConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub max_turns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_use_enforcement: Option, + /// Preserve unknown fields for forward compatibility + #[serde(flatten)] + pub extra: HashMap, +} + +/// Hermes env config (from .env file, not config.yaml) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HermesEnvConfig { + #[serde(flatten)] + pub vars: HashMap, +} + +// ============================================================================ +// Core YAML Read Functions +// ============================================================================ + +/// 读取 Hermes 配置文件为 serde_yaml::Value +/// +/// 如果文件不存在,返回空 Mapping +pub fn read_hermes_config() -> Result { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + if content.trim().is_empty() { + return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new())); + } + + serde_yaml::from_str(&content) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}"))) +} + +// ============================================================================ +// YAML Section-Level Replacement +// ============================================================================ + +/// Check if a line is a YAML top-level key (mapping key at column 0). +/// +/// A top-level key line must: +/// - Start at column 0 (no leading whitespace) +/// - Not be empty or whitespace-only +/// - Not be a comment (starting with `#`) +/// - Not be a sequence item (starting with `-`) +/// - Contain `:` followed by space, tab, newline, or end-of-line +fn is_top_level_key_line(line: &str) -> bool { + if line.is_empty() { + return false; + } + let first_char = line.as_bytes()[0]; + if first_char == b' ' || first_char == b'\t' || first_char == b'#' || first_char == b'-' { + return false; + } + if let Some(colon_pos) = line.find(':') { + let after_colon = &line[colon_pos + 1..]; + after_colon.is_empty() + || after_colon.starts_with(' ') + || after_colon.starts_with('\t') + } else { + false + } +} + +/// Find the byte range of a top-level YAML section. +/// +/// A YAML top-level key is a line that starts at column 0 (no leading +/// whitespace), is not a comment, and contains `:` after the key name. +/// +/// Returns `(start_byte_inclusive, end_byte_exclusive)` or `None` if not found. +fn find_yaml_section_range(raw: &str, section_key: &str) -> Option<(usize, usize)> { + let target = format!("{}:", section_key); + let mut section_start = None; + let mut offset = 0; + + for line in raw.split('\n') { + if section_start.is_none() && is_top_level_key_line(line) && line.starts_with(&target) { + // Verify exact match: after "key:" must be whitespace or EOL + let after_target = &line[target.len()..]; + if after_target.is_empty() + || after_target.starts_with(' ') + || after_target.starts_with('\t') + || after_target.starts_with('\r') + { + section_start = Some(offset); + } + } else if section_start.is_some() && is_top_level_key_line(line) { + // Found the next top-level key — this is the end of our section + return Some((section_start.unwrap(), offset)); + } + offset += line.len() + 1; // +1 for the \n + } + + // Section extends to end of file + section_start.map(|start| (start, raw.len())) +} + +/// Serialize a section key + value into a YAML fragment like: +/// +/// ```yaml +/// model: +/// default: "anthropic/claude-opus-4-6" +/// provider: "openrouter" +/// ``` +fn serialize_yaml_section(key: &str, value: &serde_yaml::Value) -> Result { + let mut section = serde_yaml::Mapping::new(); + section.insert( + serde_yaml::Value::String(key.to_string()), + value.clone(), + ); + let yaml_str = serde_yaml::to_string(&serde_yaml::Value::Mapping(section)) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML section '{key}': {e}")))?; + Ok(yaml_str) +} + +/// Replace a YAML section in raw text, or append it if not found. +fn replace_yaml_section( + raw: &str, + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let serialized = serialize_yaml_section(section_key, value)?; + + if let Some((start, end)) = find_yaml_section_range(raw, section_key) { + let mut result = String::with_capacity(raw.len()); + result.push_str(&raw[..start]); + result.push_str(&serialized); + // Ensure proper separation between sections + let remainder = &raw[end..]; + if !serialized.ends_with('\n') && !remainder.is_empty() && !remainder.starts_with('\n') { + result.push('\n'); + } + result.push_str(remainder); + Ok(result) + } else { + // Section not found — append at end + let mut result = raw.to_string(); + if !result.is_empty() && !result.ends_with('\n') { + result.push('\n'); + } + result.push_str(&serialized); + if !result.ends_with('\n') { + result.push('\n'); + } + Ok(result) + } +} + +// ============================================================================ +// Backup & Cleanup +// ============================================================================ + +fn create_hermes_backup(source: &str) -> Result { + let backup_dir = get_app_config_dir().join("backups").join("hermes"); + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let base_id = format!("hermes_{}", Local::now().format("%Y%m%d_%H%M%S")); + let mut filename = format!("{base_id}.yaml"); + let mut backup_path = backup_dir.join(&filename); + let mut counter = 1; + + while backup_path.exists() { + filename = format!("{base_id}_{counter}.yaml"); + backup_path = backup_dir.join(&filename); + counter += 1; + } + + atomic_write(&backup_path, source.as_bytes())?; + cleanup_hermes_backups(&backup_dir)?; + Ok(backup_path) +} + +fn cleanup_hermes_backups(dir: &Path) -> Result<(), AppError> { + let retain = effective_backup_retain_count(); + let mut entries = fs::read_dir(dir) + .map_err(|e| AppError::io(dir, e))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "yaml" || ext == "yml") + .unwrap_or(false) + }) + .collect::>(); + + if entries.len() <= retain { + return Ok(()); + } + + entries.sort_by_key(|entry| entry.metadata().and_then(|m| m.modified()).ok()); + let remove_count = entries.len().saturating_sub(retain); + for entry in entries.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old Hermes config backup {}: {err}", + entry.path().display() + ); + } + } + + Ok(()) +} + +// ============================================================================ +// High-level Write Helper +// ============================================================================ + +/// Write a single top-level YAML section to config.yaml using section-level replacement. +/// +/// This preserves comments and unrelated sections while only modifying the +/// target section. +fn write_yaml_section_to_config( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let _guard = hermes_write_lock().lock()?; + write_yaml_section_to_config_locked(section_key, value) +} + +/// Inner write helper — caller must already hold the write lock. +fn write_yaml_section_to_config_locked( + section_key: &str, + value: &serde_yaml::Value, +) -> Result { + let config_path = get_hermes_config_path(); + let raw = if config_path.exists() { + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))? + } else { + String::new() + }; + + let new_raw = replace_yaml_section(&raw, section_key, value)?; + + if new_raw == raw { + return Ok(HermesWriteOutcome::default()); + } + + let backup_path = if !raw.is_empty() { + Some(create_hermes_backup(&raw)?) + } else { + None + }; + + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&config_path, new_raw.as_bytes())?; + + let warnings = scan_hermes_health_internal(&new_raw); + + log::debug!("Hermes config section '{section_key}' written to {:?}", config_path); + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + warnings, + }) +} + +// ============================================================================ +// Provider Functions +// ============================================================================ + +/// Get all custom providers as a JSON map keyed by provider name. +/// +/// The `custom_providers` section is a YAML sequence where each item has a +/// `name` field. This function converts it to a map for CC Switch consumption. +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config()?; + let providers = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .map(|seq| { + let mut map = serde_json::Map::new(); + for item in seq { + if let Some(name) = item.get("name").and_then(|n| n.as_str()) { + match yaml_to_json(item) { + Ok(json_val) => { + map.insert(name.to_string(), json_val); + } + Err(e) => { + log::warn!("Failed to convert Hermes provider '{name}' to JSON: {e}"); + } + } + } + } + map + }) + .unwrap_or_default(); + Ok(providers) +} + +/// Get a single custom provider by name. +pub fn get_provider(name: &str) -> Result, AppError> { + Ok(get_providers()?.get(name).cloned()) +} + +/// Set (upsert) a custom provider by name. +/// +/// If a provider with the same name exists, it is replaced; otherwise a new +/// entry is appended. The entire read-modify-write is done under the write +/// lock to prevent TOCTOU races. +pub fn set_provider( + name: &str, + provider_config: serde_json::Value, +) -> Result { + let _guard = hermes_write_lock().lock()?; + + let config = read_hermes_config()?; + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let mut yaml_val: serde_yaml::Value = json_to_yaml(&provider_config)?; + if let serde_yaml::Value::Mapping(ref mut m) = yaml_val { + m.insert( + serde_yaml::Value::String("name".to_string()), + serde_yaml::Value::String(name.to_string()), + ); + } + + if let Some(existing) = providers.iter_mut().find(|p| { + p.get("name") + .and_then(|n| n.as_str()) + == Some(name) + }) { + *existing = yaml_val; + } else { + providers.push(yaml_val); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +/// Remove a custom provider by name. +/// +/// The entire read-modify-write is done under the write lock to prevent +/// TOCTOU races. +pub fn remove_provider(name: &str) -> Result { + let _guard = hermes_write_lock().lock()?; + + let config = read_hermes_config()?; + let mut providers: Vec = config + .get("custom_providers") + .and_then(|v| v.as_sequence()) + .cloned() + .unwrap_or_default(); + + let original_len = providers.len(); + providers.retain(|p| { + p.get("name") + .and_then(|n| n.as_str()) + != Some(name) + }); + + if providers.len() == original_len { + return Ok(HermesWriteOutcome::default()); + } + + let providers_value = serde_yaml::Value::Sequence(providers); + write_yaml_section_to_config_locked("custom_providers", &providers_value) +} + +// ============================================================================ +// Model Config Functions +// ============================================================================ + +/// Get the `model` section as a typed config. +pub fn get_model_config() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(model_value) = config.get("model") else { + return Ok(None); + }; + let json_val = yaml_to_json(model_value)?; + let model = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes model config: {e}")))?; + Ok(Some(model)) +} + +/// Set the `model` section. +pub fn set_model_config(model: &HermesModelConfig) -> Result { + let json_val = + serde_json::to_value(model).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("model", &yaml_val) +} + +// ============================================================================ +// Agent Config Functions +// ============================================================================ + +/// Get the `agent` section as a typed config. +pub fn get_agent_config() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(agent_value) = config.get("agent") else { + return Ok(None); + }; + let json_val = yaml_to_json(agent_value)?; + let agent = serde_json::from_value(json_val) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes agent config: {e}")))?; + Ok(Some(agent)) +} + +/// Set the `agent` section. +pub fn set_agent_config(agent: &HermesAgentConfig) -> Result { + let json_val = + serde_json::to_value(agent).map_err(|e| AppError::JsonSerialize { source: e })?; + let yaml_val = json_to_yaml(&json_val)?; + write_yaml_section_to_config("agent", &yaml_val) +} + +// ============================================================================ +// .env Functions +// ============================================================================ + +/// Read the Hermes `.env` file (`~/.hermes/.env`). +/// +/// Parses dotenv format (KEY=VALUE, `#` comments, blank lines). +pub fn read_env() -> Result { + let path = get_hermes_dir().join(".env"); + if !path.exists() { + return Ok(HermesEnvConfig { + vars: HashMap::new(), + }); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let mut vars = HashMap::new(); + for line in content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + if let Some((key, value)) = trimmed.split_once('=') { + let key = key.trim().to_string(); + let value = value + .trim() + .trim_matches('"') + .trim_matches('\'') + .to_string(); + vars.insert(key, serde_json::Value::String(value)); + } + } + Ok(HermesEnvConfig { vars }) +} + +/// Write the Hermes `.env` file (`~/.hermes/.env`). +/// +/// Preserves comment lines and ordering. Keys not present in the new env are +/// removed; new keys are appended. +pub fn write_env(env: &HermesEnvConfig) -> Result { + let path = get_hermes_dir().join(".env"); + let _guard = hermes_write_lock().lock()?; + + // Read existing file to preserve comments and ordering + let existing_content = if path.exists() { + fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))? + } else { + String::new() + }; + + // Build new content: preserve comment lines, update/add key-value pairs + let mut remaining_keys: HashSet = env.vars.keys().cloned().collect(); + let mut lines: Vec = Vec::new(); + + for line in existing_content.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + lines.push(line.to_string()); + continue; + } + if let Some((key, _)) = trimmed.split_once('=') { + let key = key.trim(); + if let Some(new_value) = env.vars.get(key) { + // Update existing key + let value_str = json_value_as_env_str(new_value); + lines.push(format!("{key}={value_str}")); + remaining_keys.remove(key); + } + // If key is not in new env, it's deleted (don't add it) + } else { + lines.push(line.to_string()); + } + } + + // Add new keys that weren't in the original file (sorted for determinism) + let mut new_keys: Vec = remaining_keys.into_iter().collect(); + new_keys.sort(); + for key in new_keys { + if let Some(value) = env.vars.get(&key) { + let value_str = json_value_as_env_str(value); + lines.push(format!("{key}={value_str}")); + } + } + + let mut new_content = lines.join("\n"); + if !new_content.is_empty() && !new_content.ends_with('\n') { + new_content.push('\n'); + } + + if new_content == existing_content { + return Ok(HermesWriteOutcome::default()); + } + + // Backup if file existed with content + let backup_path = if !existing_content.is_empty() { + Some(create_hermes_backup(&existing_content)?) + } else { + None + }; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + atomic_write(&path, new_content.as_bytes())?; + + Ok(HermesWriteOutcome { + backup_path: backup_path.map(|p| p.display().to_string()), + warnings: Vec::new(), + }) +} + +/// Convert a serde_json::Value to a string suitable for .env files. +fn json_value_as_env_str(value: &serde_json::Value) -> String { + match value { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +// ============================================================================ +// Health Check +// ============================================================================ + +/// Scan Hermes config for known configuration hazards. +/// +/// Parse failures are reported as warnings (not errors) so the UI can +/// display them without blocking. +pub fn scan_hermes_config_health() -> Result, AppError> { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(Vec::new()); + } + let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + Ok(scan_hermes_health_internal(&content)) +} + +fn scan_hermes_health_internal(content: &str) -> Vec { + let mut warnings = Vec::new(); + + if content.trim().is_empty() { + return warnings; + } + + match serde_yaml::from_str::(content) { + Ok(config) => { + // Check for model section issues + if let Some(model) = config.get("model") { + if model.get("default").is_none() && model.get("provider").is_none() { + warnings.push(HermesHealthWarning { + code: "model_no_default".to_string(), + message: "No default model or provider configured in 'model' section" + .to_string(), + path: Some("model".to_string()), + }); + } + } + + // Check custom_providers is a sequence + if let Some(providers) = config.get("custom_providers") { + if !providers.is_sequence() { + warnings.push(HermesHealthWarning { + code: "custom_providers_not_list".to_string(), + message: + "custom_providers should be a YAML list (sequence), not a mapping" + .to_string(), + path: Some("custom_providers".to_string()), + }); + } + } + } + Err(err) => { + warnings.push(HermesHealthWarning { + code: "config_parse_failed".to_string(), + message: format!("Hermes config could not be parsed as YAML: {err}"), + path: Some(get_hermes_config_path().display().to_string()), + }); + } + } + + warnings +} + +// ============================================================================ +// MCP Section Access (for mcp/hermes.rs to use in Phase 4) +// ============================================================================ + +/// Get the `mcp_servers` section as a YAML Mapping. +#[allow(dead_code)] +pub fn get_mcp_servers_yaml() -> Result { + let config = read_hermes_config()?; + Ok(config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + .cloned() + .unwrap_or_default()) +} + +/// Set the `mcp_servers` section. +#[allow(dead_code)] +pub fn set_mcp_servers_yaml(servers: &serde_yaml::Mapping) -> Result<(), AppError> { + let value = serde_yaml::Value::Mapping(servers.clone()); + write_yaml_section_to_config("mcp_servers", &value)?; + Ok(()) +} + +// ============================================================================ +// YAML ↔ JSON Conversion Helpers +// ============================================================================ + +/// Convert a `serde_yaml::Value` to a `serde_json::Value`. +pub(crate) fn yaml_to_json(yaml: &serde_yaml::Value) -> Result { + // Serialize YAML value to string, then parse as JSON value. + // This handles all type mappings correctly. + let yaml_str = serde_yaml::to_string(yaml) + .map_err(|e| AppError::Config(format!("Failed to serialize YAML value: {e}")))?; + serde_yaml::from_str::(&yaml_str) + .map_err(|e| AppError::Config(format!("Failed to convert YAML to JSON: {e}"))) +} + +/// Convert a `serde_json::Value` to a `serde_yaml::Value`. +fn json_to_yaml(json: &serde_json::Value) -> Result { + let json_str = serde_json::to_string(json) + .map_err(|e| AppError::Config(format!("Failed to serialize JSON value: {e}")))?; + serde_yaml::from_str(&json_str) + .map_err(|e| AppError::Config(format!("Failed to convert JSON to YAML: {e}"))) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use std::sync::{Mutex, OnceLock}; + + fn test_guard() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + /// Run a test with an isolated temp home directory. + /// + /// Saves and restores `CC_SWITCH_TEST_HOME` to avoid interfering with + /// parallel tests in other modules. + fn with_test_home(test_fn: impl FnOnce() -> T) -> T { + let _guard = test_guard(); + let tmp = tempfile::tempdir().unwrap(); + let old_test_home = std::env::var_os("CC_SWITCH_TEST_HOME"); + std::env::set_var("CC_SWITCH_TEST_HOME", tmp.path()); + let result = test_fn(); + match old_test_home { + Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value), + None => std::env::remove_var("CC_SWITCH_TEST_HOME"), + } + result + } + + // ---- find_yaml_section_range tests ---- + + #[test] + fn find_section_in_multi_section_yaml() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +agent: + max_turns: 10 +custom_providers: + - name: foo +"; + let (start, end) = find_yaml_section_range(yaml, "agent").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("agent:")); + assert!(section.contains("max_turns")); + assert!(!section.contains("custom_providers")); + } + + #[test] + fn find_section_at_end_of_file() { + let yaml = "\ +model: + default: gpt-4 +agent: + max_turns: 10 +"; + let (start, end) = find_yaml_section_range(yaml, "agent").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("agent:")); + assert!(section.contains("max_turns")); + assert_eq!(end, yaml.len()); + } + + #[test] + fn find_section_not_found() { + let yaml = "\ +model: + default: gpt-4 +"; + assert!(find_yaml_section_range(yaml, "agent").is_none()); + } + + #[test] + fn find_section_with_comments_between() { + let yaml = "\ +model: + default: gpt-4 + +# This is a comment + # indented comment + +agent: + max_turns: 10 +"; + // model section should span from start to "agent:" + let (start, end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("model:")); + // Comments and blank lines between sections are included in the prior section + assert!(section.contains("# This is a comment")); + } + + #[test] + fn find_section_with_empty_lines() { + let yaml = "\ +model: + default: gpt-4 + +agent: + max_turns: 10 +"; + let (start, end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..end]; + assert!(section.starts_with("model:")); + // Empty lines don't terminate a section + assert!(section.contains('\n')); + } + + #[test] + fn find_section_does_not_match_substring_key() { + let yaml = "\ +model_extra: + foo: bar +model: + default: gpt-4 +"; + let (start, _end) = find_yaml_section_range(yaml, "model").unwrap(); + let section = &yaml[start..]; + // Should match "model:", not "model_extra:" + assert!(section.starts_with("model:")); + assert!(!section.starts_with("model_extra:")); + } + + // ---- replace_yaml_section tests ---- + + #[test] + fn replace_existing_section() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +agent: + max_turns: 10 +"; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("claude-opus-4-6".to_string()), + ); + m.insert( + serde_yaml::Value::String("provider".to_string()), + serde_yaml::Value::String("anthropic".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + // The result should still contain the agent section + assert!(result.contains("agent:")); + assert!(result.contains("max_turns")); + // And the model section should be updated + assert!(result.contains("claude-opus-4-6")); + assert!(result.contains("anthropic")); + assert!(!result.contains("gpt-4")); + assert!(!result.contains("openai")); + } + + #[test] + fn append_new_section() { + let yaml = "\ +model: + default: gpt-4 +"; + let new_agent = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("max_turns".to_string()), + serde_yaml::Value::Number(serde_yaml::Number::from(50)), + ); + m + }); + + let result = replace_yaml_section(yaml, "agent", &new_agent).unwrap(); + assert!(result.contains("model:")); + assert!(result.contains("gpt-4")); + assert!(result.contains("agent:")); + assert!(result.contains("max_turns: 50")); + } + + #[test] + fn replace_section_in_empty_file() { + let yaml = ""; + let new_model = serde_yaml::Value::Mapping({ + let mut m = serde_yaml::Mapping::new(); + m.insert( + serde_yaml::Value::String("default".to_string()), + serde_yaml::Value::String("gpt-4".to_string()), + ); + m + }); + + let result = replace_yaml_section(yaml, "model", &new_model).unwrap(); + assert!(result.contains("model:")); + assert!(result.contains("gpt-4")); + assert!(result.ends_with('\n')); + } + + // ---- Provider CRUD via mock config ---- + + #[test] + #[serial] + fn provider_crud_roundtrip() { + with_test_home(|| { + // Initially no providers + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + + // Add a provider + let config = serde_json::json!({ + "base_url": "https://openrouter.ai/api/v1", + "api_key": "sk-or-test" + }); + set_provider("openrouter", config).unwrap(); + + let providers = get_providers().unwrap(); + assert_eq!(providers.len(), 1); + assert!(providers.contains_key("openrouter")); + + let provider = get_provider("openrouter").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://openrouter.ai/api/v1"); + assert_eq!(provider["name"], "openrouter"); + + // Update the provider + let config2 = serde_json::json!({ + "base_url": "https://openrouter.ai/api/v2", + "api_key": "sk-or-updated" + }); + set_provider("openrouter", config2).unwrap(); + + let provider = get_provider("openrouter").unwrap().unwrap(); + assert_eq!(provider["base_url"], "https://openrouter.ai/api/v2"); + + // Remove the provider + remove_provider("openrouter").unwrap(); + let providers = get_providers().unwrap(); + assert!(providers.is_empty()); + }); + } + + // ---- .env read/write tests ---- + + #[test] + #[serial] + fn env_read_write_roundtrip() { + with_test_home(|| { + // Write initial env + let env = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("sk-test-123".to_string()), + ); + m.insert( + "DEBUG".to_string(), + serde_json::Value::String("true".to_string()), + ); + m + }, + }; + write_env(&env).unwrap(); + + // Read back + let env_read = read_env().unwrap(); + assert_eq!( + env_read.vars.get("API_KEY").unwrap().as_str().unwrap(), + "sk-test-123" + ); + assert_eq!( + env_read.vars.get("DEBUG").unwrap().as_str().unwrap(), + "true" + ); + + // Update: remove DEBUG, add NEW_VAR + let env2 = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("sk-test-456".to_string()), + ); + m.insert( + "NEW_VAR".to_string(), + serde_json::Value::String("hello".to_string()), + ); + m + }, + }; + write_env(&env2).unwrap(); + + let env_read2 = read_env().unwrap(); + assert_eq!( + env_read2.vars.get("API_KEY").unwrap().as_str().unwrap(), + "sk-test-456" + ); + assert!(env_read2.vars.get("DEBUG").is_none()); + assert_eq!( + env_read2.vars.get("NEW_VAR").unwrap().as_str().unwrap(), + "hello" + ); + }); + } + + #[test] + #[serial] + fn env_preserves_comments() { + with_test_home(|| { + let hermes_dir = get_hermes_dir(); + fs::create_dir_all(&hermes_dir).unwrap(); + let env_path = hermes_dir.join(".env"); + fs::write( + &env_path, + "# Hermes environment config\nAPI_KEY=old-key\n# Keep this comment\nDEBUG=true\n", + ) + .unwrap(); + + let env = HermesEnvConfig { + vars: { + let mut m = HashMap::new(); + m.insert( + "API_KEY".to_string(), + serde_json::Value::String("new-key".to_string()), + ); + m.insert( + "DEBUG".to_string(), + serde_json::Value::String("false".to_string()), + ); + m + }, + }; + write_env(&env).unwrap(); + + let content = fs::read_to_string(&env_path).unwrap(); + assert!(content.contains("# Hermes environment config")); + assert!(content.contains("# Keep this comment")); + assert!(content.contains("API_KEY=new-key")); + assert!(content.contains("DEBUG=false")); + }); + } + + // ---- Model/Agent config tests ---- + + #[test] + #[serial] + fn model_config_roundtrip() { + with_test_home(|| { + // Initially none + assert!(get_model_config().unwrap().is_none()); + + let model = HermesModelConfig { + default: Some("anthropic/claude-opus-4-6".to_string()), + provider: Some("openrouter".to_string()), + base_url: Some("https://openrouter.ai/api/v1".to_string()), + context_length: Some(200000), + max_tokens: None, + extra: HashMap::new(), + }; + set_model_config(&model).unwrap(); + + let read_model = get_model_config().unwrap().unwrap(); + assert_eq!( + read_model.default.as_deref(), + Some("anthropic/claude-opus-4-6") + ); + assert_eq!(read_model.provider.as_deref(), Some("openrouter")); + assert_eq!(read_model.context_length, Some(200000)); + }); + } + + #[test] + #[serial] + fn agent_config_roundtrip() { + with_test_home(|| { + assert!(get_agent_config().unwrap().is_none()); + + let agent = HermesAgentConfig { + max_turns: Some(50), + reasoning_effort: Some("high".to_string()), + tool_use_enforcement: None, + extra: HashMap::new(), + }; + set_agent_config(&agent).unwrap(); + + let read_agent = get_agent_config().unwrap().unwrap(); + assert_eq!(read_agent.max_turns, Some(50)); + assert_eq!(read_agent.reasoning_effort.as_deref(), Some("high")); + }); + } + + // ---- Health check tests ---- + + #[test] + fn health_check_on_invalid_yaml() { + let warnings = scan_hermes_health_internal("not: valid: yaml: ["); + assert!(!warnings.is_empty()); + assert_eq!(warnings[0].code, "config_parse_failed"); + } + + #[test] + fn health_check_model_no_default() { + let yaml = "model:\n context_length: 200000\n"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.iter().any(|w| w.code == "model_no_default")); + } + + #[test] + fn health_check_custom_providers_not_list() { + let yaml = "custom_providers:\n foo:\n base_url: http://localhost\n"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings + .iter() + .any(|w| w.code == "custom_providers_not_list")); + } + + #[test] + fn health_check_valid_config() { + let yaml = "\ +model: + default: gpt-4 + provider: openai +custom_providers: + - name: openrouter + base_url: https://openrouter.ai/api/v1 +"; + let warnings = scan_hermes_health_internal(yaml); + assert!(warnings.is_empty()); + } + + // ---- yaml_to_json / json_to_yaml ---- + + #[test] + fn yaml_json_conversion_roundtrip() { + let json = serde_json::json!({ + "name": "test", + "count": 42, + "nested": { + "flag": true + } + }); + let yaml = json_to_yaml(&json).unwrap(); + let back = yaml_to_json(&yaml).unwrap(); + assert_eq!(json, back); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b6cc0b973..5788a2f0a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -541,6 +541,13 @@ pub fn run() { Ok(_) => log::debug!("○ No new OpenClaw providers to import"), Err(e) => log::warn!("✗ Failed to import OpenClaw providers: {e}"), } + match crate::services::provider::import_hermes_providers_from_live(&app_state) { + Ok(count) if count > 0 => { + log::info!("✓ Imported {count} Hermes provider(s) from live config"); + } + Ok(_) => log::debug!("○ No new Hermes providers to import"), + Err(e) => log::warn!("✗ Failed to import Hermes providers: {e}"), + } // 2. OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) { @@ -1236,6 +1243,17 @@ pub fn run() { commands::set_openclaw_env, commands::get_openclaw_tools, commands::set_openclaw_tools, + // Hermes specific + commands::import_hermes_providers_from_live, + commands::get_hermes_live_provider_ids, + commands::get_hermes_live_provider, + commands::scan_hermes_config_health, + commands::get_hermes_model_config, + commands::set_hermes_model_config, + commands::get_hermes_agent_config, + commands::set_hermes_agent_config, + commands::get_hermes_env, + commands::set_hermes_env, // Global upstream proxy commands::get_global_proxy_url, commands::set_global_proxy_url, diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index ac7492fe6..e50fd1023 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -42,10 +42,8 @@ pub(crate) fn provider_exists_in_live_config( .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => crate::openclaw_config::get_providers() .map(|providers| providers.contains_key(provider_id)), - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - Ok(false) - } + AppType::Hermes => crate::hermes_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), _ => Ok(false), } } @@ -795,11 +793,8 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re } } AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!( - "Hermes provider '{}' write to live config not yet implemented", - provider.id - ); + crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone())?; + log::debug!("Hermes provider '{}' written to live config", provider.id); } } Ok(()) @@ -1005,8 +1000,9 @@ pub fn read_live_settings(app_type: AppType) -> Result { "Hermes configuration file not found", )); } - // Return empty object until hermes_config is implemented - Ok(json!({})) + let yaml_config = crate::hermes_config::read_hermes_config()?; + let config = crate::hermes_config::yaml_to_json(&yaml_config)?; + Ok(config) } } } @@ -1339,6 +1335,76 @@ pub fn import_openclaw_providers_from_live(state: &AppState) -> Result Result { + use crate::hermes_config; + + let providers = hermes_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0; + let existing_ids = state.db.get_provider_ids("hermes")?; + + for (name, config) in providers { + // Validate: skip entries with empty name + if name.trim().is_empty() { + log::warn!("Skipping Hermes provider with empty name"); + continue; + } + + // Skip if already exists in database + if existing_ids.contains(&name) { + log::debug!("Hermes provider '{name}' already exists in database, skipping"); + continue; + } + + // Create provider + let mut provider = Provider::with_id(name.clone(), name.clone(), config, None); + provider.meta = Some(crate::provider::ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + // Save to database + if let Err(e) = state.db.save_provider("hermes", &provider) { + log::warn!("Failed to import Hermes provider '{name}': {e}"); + continue; + } + + imported += 1; + log::info!("Imported Hermes provider '{name}' from live config"); + } + + Ok(imported) +} + +/// Remove a Hermes provider from live config +/// +/// This removes a specific provider from ~/.hermes/config.yaml +/// without affecting other providers in the file. +pub fn remove_hermes_provider_from_live(provider_id: &str) -> Result<(), AppError> { + use crate::hermes_config; + + // Check if Hermes config directory exists + if !hermes_config::get_hermes_dir().exists() { + log::debug!( + "Hermes config directory doesn't exist, skipping removal of '{provider_id}'" + ); + return Ok(()); + } + + hermes_config::remove_provider(provider_id)?; + log::info!("Hermes provider '{provider_id}' removed from live config"); + + Ok(()) +} + /// Remove an OpenClaw provider from live config /// /// This removes a specific provider from ~/.openclaw/openclaw.json diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index d8dcce3fe..cae1cd5b9 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -21,8 +21,9 @@ use crate::store::AppState; // Re-export sub-module functions for external access pub use live::{ - import_default_config, import_openclaw_providers_from_live, - import_opencode_providers_from_live, read_live_settings, sync_current_to_live, + import_default_config, import_hermes_providers_from_live, + import_openclaw_providers_from_live, import_opencode_providers_from_live, read_live_settings, + sync_current_to_live, }; // Internal re-exports (pub(crate)) @@ -35,7 +36,8 @@ pub(crate) use live::{ // Internal re-exports use live::{ - remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live, + remove_hermes_provider_from_live, remove_openclaw_provider_from_live, + remove_opencode_provider_from_live, write_gemini_live, }; use usage::validate_usage_script; @@ -1282,12 +1284,7 @@ impl ProviderService { match app_type { AppType::OpenCode => remove_opencode_provider_from_live(id)?, AppType::OpenClaw => remove_openclaw_provider_from_live(id)?, - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!( - "Hermes provider '{id}' removal from live config not yet implemented" - ); - } + AppType::Hermes => remove_hermes_provider_from_live(id)?, _ => {} } } @@ -1351,8 +1348,7 @@ impl ProviderService { remove_openclaw_provider_from_live(id)?; } AppType::Hermes => { - // TODO: hermes_config module not yet implemented - log::debug!("Hermes provider '{id}' removal from live config not yet implemented"); + remove_hermes_provider_from_live(id)?; } _ => { return Err(AppError::Message(format!( @@ -1542,10 +1538,7 @@ impl ProviderService { let rollback_result = match app_type { AppType::OpenCode => remove_opencode_provider_from_live(&provider.id), AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id), - AppType::Hermes => { - // TODO: hermes_config module not yet implemented - Ok(()) - } + AppType::Hermes => remove_hermes_provider_from_live(&provider.id), _ => Ok(()), };