diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs index 16d5756c..a80e7871 100644 --- a/src-tauri/src/app_config.rs +++ b/src-tauri/src/app_config.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::str::FromStr; +use crate::prompt_files::prompt_file_path; use crate::services::skill::SkillStore; /// MCP 服务器应用状态(标记应用到哪些客户端) @@ -27,6 +28,7 @@ impl McpApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -38,6 +40,7 @@ impl McpApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } @@ -57,6 +60,9 @@ impl McpApps { if self.opencode { apps.push(AppType::OpenCode); } + if self.hermes { + apps.push(AppType::Hermes); + } apps } @@ -77,6 +83,8 @@ pub struct SkillApps { pub gemini: bool, #[serde(default)] pub opencode: bool, + #[serde(default)] + pub hermes: bool, } impl SkillApps { @@ -86,6 +94,7 @@ impl SkillApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => false, } } @@ -96,12 +105,13 @@ impl SkillApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => {} } } pub fn is_empty(&self) -> bool { - !self.claude && !self.codex && !self.gemini && !self.opencode + !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes } pub fn only(app: &AppType) -> Self { @@ -125,6 +135,7 @@ impl SkillApps { self.codex |= other.codex; self.gemini |= other.gemini; self.opencode |= other.opencode; + self.hermes |= other.hermes; } } @@ -223,6 +234,8 @@ pub struct McpRoot { #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub opencode: McpConfig, #[serde(default, skip_serializing_if = "McpConfig::is_empty")] + pub hermes: McpConfig, + #[serde(default, skip_serializing_if = "McpConfig::is_empty")] pub openclaw: McpConfig, } @@ -236,6 +249,7 @@ impl Default for McpRoot { codex: McpConfig::default(), gemini: McpConfig::default(), opencode: McpConfig::default(), + hermes: McpConfig::default(), openclaw: McpConfig::default(), } } @@ -260,6 +274,8 @@ pub struct PromptRoot { #[serde(default)] pub opencode: PromptConfig, #[serde(default)] + pub hermes: PromptConfig, + #[serde(default)] pub openclaw: PromptConfig, } @@ -275,6 +291,7 @@ pub enum AppType { Codex, Gemini, OpenCode, + Hermes, OpenClaw, } @@ -285,12 +302,16 @@ impl AppType { AppType::Codex => "codex", AppType::Gemini => "gemini", AppType::OpenCode => "opencode", + AppType::Hermes => "hermes", AppType::OpenClaw => "openclaw", } } pub fn is_additive_mode(&self) -> bool { - matches!(self, AppType::OpenCode | AppType::OpenClaw) + matches!( + self, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw + ) } pub fn supports_failover(&self) -> bool { @@ -303,6 +324,7 @@ impl AppType { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] .into_iter() @@ -325,14 +347,15 @@ impl FromStr for AppType { "codex" => Ok(AppType::Codex), "gemini" => Ok(AppType::Gemini), "opencode" => Ok(AppType::OpenCode), + "hermes" => Ok(AppType::Hermes), "openclaw" => Ok(AppType::OpenClaw), other => Err(AppError::localized( "unsupported_app", format!( - "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。" + "不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw。" ), format!( - "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw." + "Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw." ), )), } @@ -354,6 +377,9 @@ pub struct CommonConfigSnippets { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw: Option, } @@ -366,6 +392,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex.as_ref(), AppType::Gemini => self.gemini.as_ref(), AppType::OpenCode => self.opencode.as_ref(), + AppType::Hermes => self.hermes.as_ref(), AppType::OpenClaw => self.openclaw.as_ref(), } } @@ -377,6 +404,7 @@ impl CommonConfigSnippets { AppType::Codex => self.codex = snippet, AppType::Gemini => self.gemini = snippet, AppType::OpenCode => self.opencode = snippet, + AppType::Hermes => self.hermes = snippet, AppType::OpenClaw => self.openclaw = snippet, } } @@ -418,6 +446,7 @@ impl Default for MultiAppConfig { apps.insert("codex".to_string(), ProviderManager::default()); apps.insert("gemini".to_string(), ProviderManager::default()); apps.insert("opencode".to_string(), ProviderManager::default()); + apps.insert("hermes".to_string(), ProviderManager::default()); apps.insert("openclaw".to_string(), ProviderManager::default()); Self { @@ -512,6 +541,13 @@ impl MultiAppConfig { updated = true; } + if !config.apps.contains_key("hermes") { + config + .apps + .insert("hermes".to_string(), ProviderManager::default()); + updated = true; + } + if !config.apps.contains_key("openclaw") { config .apps @@ -583,6 +619,7 @@ impl MultiAppConfig { AppType::Codex => &self.mcp.codex, AppType::Gemini => &self.mcp.gemini, AppType::OpenCode => &self.mcp.opencode, + AppType::Hermes => &self.mcp.hermes, AppType::OpenClaw => &self.mcp.openclaw, } } @@ -594,10 +631,137 @@ impl MultiAppConfig { AppType::Codex => &mut self.mcp.codex, AppType::Gemini => &mut self.mcp.gemini, AppType::OpenCode => &mut self.mcp.opencode, + AppType::Hermes => &mut self.mcp.hermes, AppType::OpenClaw => &mut self.mcp.openclaw, } } + /// 创建默认配置并自动导入已存在的提示词文件 + fn default_with_auto_import() -> Result { + log::info!("首次启动,创建默认配置并检测提示词文件"); + + let mut config = Self::default(); + + // 为每个应用尝试自动导入提示词 + Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?; + Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?; + + Ok(config) + } + + /// 已存在配置文件时的 Prompt 自动导入逻辑 + /// + /// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。 + /// 判定规则: + /// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户) + /// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md) + /// + /// 返回值: + /// - Ok(true) 表示至少有一个应用成功导入了提示词 + /// - Ok(false) 表示无需导入或未导入任何内容 + fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result { + // 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入 + if !self.prompts.claude.prompts.is_empty() + || !self.prompts.codex.prompts.is_empty() + || !self.prompts.gemini.prompts.is_empty() + || !self.prompts.opencode.prompts.is_empty() + || !self.prompts.hermes.prompts.is_empty() + || !self.prompts.openclaw.prompts.is_empty() + { + return Ok(false); + } + + log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入"); + + let mut imported = false; + for app in [ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes, + AppType::OpenClaw, + ] { + // 复用已有的单应用导入逻辑 + if Self::auto_import_prompt_if_exists(self, app)? { + imported = true; + } + } + + Ok(imported) + } + + /// 检查并自动导入单个应用的提示词文件 + /// + /// 返回值: + /// - Ok(true) 表示成功导入了非空文件 + /// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败) + fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result { + let file_path = prompt_file_path(&app)?; + + // 检查文件是否存在 + if !file_path.exists() { + log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}"); + return Ok(false); + } + + // 读取文件内容 + let content = match std::fs::read_to_string(&file_path) { + Ok(c) => c, + Err(e) => { + log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}"); + return Ok(false); // 失败时不中断,继续处理其他应用 + } + }; + + // 检查内容是否为空 + if content.trim().is_empty() { + log::debug!("提示词文件内容为空,跳过导入: {file_path:?}"); + return Ok(false); + } + + log::info!("发现提示词文件,自动导入: {file_path:?}"); + + // 创建提示词对象 + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + let id = format!("auto-imported-{timestamp}"); + let prompt = crate::prompt::Prompt { + id: id.clone(), + name: format!( + "Auto-imported Prompt {}", + chrono::Local::now().format("%Y-%m-%d %H:%M") + ), + content, + description: Some("Automatically imported on first launch".to_string()), + enabled: true, // 自动启用 + created_at: Some(timestamp), + updated_at: Some(timestamp), + }; + + // 插入到对应的应用配置中 + let prompts = match app { + AppType::Claude => &mut config.prompts.claude.prompts, + AppType::Codex => &mut config.prompts.codex.prompts, + AppType::Gemini => &mut config.prompts.gemini.prompts, + AppType::OpenCode => &mut config.prompts.opencode.prompts, + AppType::Hermes => &mut config.prompts.hermes.prompts, + AppType::OpenClaw => &mut config.prompts.openclaw.prompts, + }; + + prompts.insert(id, prompt); + + log::info!("自动导入完成: {}", app.as_str()); + Ok(true) + } + /// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构 /// /// 迁移策略: @@ -623,12 +787,14 @@ impl MultiAppConfig { AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, ] { let old_servers = match app { AppType::Claude => &self.mcp.claude.servers, AppType::Codex => &self.mcp.codex.servers, AppType::Gemini => &self.mcp.gemini.servers, AppType::OpenCode => &self.mcp.opencode.servers, + AppType::Hermes => &self.mcp.hermes.servers, AppType::OpenClaw => continue, }; @@ -733,6 +899,7 @@ impl MultiAppConfig { self.mcp.codex = McpConfig::default(); self.mcp.gemini = McpConfig::default(); self.mcp.opencode = McpConfig::default(); + self.mcp.hermes = McpConfig::default(); Ok(true) } diff --git a/src-tauri/src/cli/commands/config_common.rs b/src-tauri/src/cli/commands/config_common.rs index 0bc62e74..17144a2c 100644 --- a/src-tauri/src/cli/commands/config_common.rs +++ b/src-tauri/src/cli/commands/config_common.rs @@ -133,7 +133,11 @@ fn set( }; let snippet = match app_type { - AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| { AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string())) })?; diff --git a/src-tauri/src/cli/commands/failover.rs b/src-tauri/src/cli/commands/failover.rs index e9ac5bbc..f14cd379 100644 --- a/src-tauri/src/cli/commands/failover.rs +++ b/src-tauri/src/cli/commands/failover.rs @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) -> AppType::Claude => takeovers.claude, AppType::Codex => takeovers.codex, AppType::Gemini => takeovers.gemini, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/commands/mcp.rs b/src-tauri/src/cli/commands/mcp.rs index 34639b3c..578bed8a 100644 --- a/src-tauri/src/cli/commands/mcp.rs +++ b/src-tauri/src/cli/commands/mcp.rs @@ -265,6 +265,7 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> { AppType::Codex => McpService::import_from_codex(&state)?, AppType::Gemini => McpService::import_from_gemini(&state)?, AppType::OpenCode => 0, + AppType::Hermes => 0, AppType::OpenClaw => 0, }; diff --git a/src-tauri/src/cli/commands/provider_input.rs b/src-tauri/src/cli/commands/provider_input.rs index 3c378f73..ae4eff77 100644 --- a/src-tauri/src/cli/commands/provider_input.rs +++ b/src-tauri/src/cli/commands/provider_input.rs @@ -85,6 +85,7 @@ pub fn prompt_settings_config_for_add( (AppType::Codex, ProviderAddMode::ThirdParty) => prompt_codex_config(None), (AppType::Gemini, _) => prompt_gemini_config(None), (AppType::OpenCode, _) => Ok(json!({})), + (AppType::Hermes, _) => Ok(json!({})), (AppType::OpenClaw, _) => Ok(json!({})), } } @@ -321,6 +322,7 @@ pub fn prompt_settings_config( } AppType::Gemini => prompt_gemini_config(current), AppType::OpenCode => Ok(current.cloned().unwrap_or_else(|| json!({}))), + AppType::Hermes => Ok(current.cloned().unwrap_or_else(|| json!({}))), AppType::OpenClaw => Ok(current.cloned().unwrap_or_else(|| json!({}))), } } @@ -827,6 +829,49 @@ pub fn display_provider_summary(provider: &Provider, app_type: &AppType) { println!(" {}: {}", texts::model_label(), models.len()); } } + AppType::Hermes => { + if let Some(api_key) = provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + { + println!( + " {}: {}", + texts::api_key_display_label(), + mask_api_key(api_key) + ); + } + if let Some(base_url) = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::base_url_display_label(), base_url); + } + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|v| v.as_str()) + { + println!(" {}: {}", texts::model_label(), model); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_object()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } else if let Some(models) = provider + .settings_config + .get("models") + .and_then(|v| v.as_array()) + { + println!(" {}: {}", texts::model_label(), models.len()); + } + } AppType::OpenClaw => { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/commands/provider_inspect.rs b/src-tauri/src/cli/commands/provider_inspect.rs index 0e8374c2..61ca3ba2 100644 --- a/src-tauri/src/cli/commands/provider_inspect.rs +++ b/src-tauri/src/cli/commands/provider_inspect.rs @@ -341,6 +341,21 @@ fn model_fetch_target( })?, strategy: ProviderModelFetchStrategy::Bearer, }), + AppType::Hermes => Ok(ModelFetchTarget { + base_url, + auth_value: provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + AppError::Message(format!("Missing API key for provider '{}'", provider.id)) + })?, + strategy: ProviderModelFetchStrategy::Bearer, + }), AppType::OpenClaw => Ok(ModelFetchTarget { base_url, auth_value: provider diff --git a/src-tauri/src/cli/failover_policy.rs b/src-tauri/src/cli/failover_policy.rs index 198e53a0..21e789ca 100644 --- a/src-tauri/src/cli/failover_policy.rs +++ b/src-tauri/src/cli/failover_policy.rs @@ -85,6 +85,6 @@ fn takeover_enabled_for(takeover: &ProxyTakeoverStatus, app_type: &AppType) -> b AppType::Claude => takeover.claude, AppType::Codex => takeover.codex, AppType::Gemini => takeover.gemini, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } diff --git a/src-tauri/src/cli/i18n.rs b/src-tauri/src/cli/i18n.rs index ceb0b6b8..1d7020bb 100644 --- a/src-tauri/src/cli/i18n.rs +++ b/src-tauri/src/cli/i18n.rs @@ -1624,6 +1624,55 @@ pub mod texts { } } + pub fn tui_label_hermes_api_mode() -> &'static str { + if is_chinese() { + "API 模式" + } else { + "API Mode" + } + } + + pub fn tui_label_hermes_models() -> &'static str { + if is_chinese() { + "模型列表" + } else { + "Models" + } + } + + pub fn tui_hermes_api_mode_value(api_mode: &str) -> &'static str { + match api_mode { + "codex_responses" => { + if is_chinese() { + "Codex Responses API" + } else { + "Codex Responses API" + } + } + "anthropic_messages" => { + if is_chinese() { + "Anthropic Messages" + } else { + "Anthropic Messages" + } + } + "bedrock_converse" => { + if is_chinese() { + "Bedrock Converse" + } else { + "Bedrock Converse" + } + } + _ => { + if is_chinese() { + "OpenAI Chat Completions" + } else { + "OpenAI Chat Completions" + } + } + } + } + pub fn tui_label_openclaw_status() -> &'static str { if is_chinese() { "状态" @@ -1764,6 +1813,36 @@ pub mod texts { } } + pub fn tui_hermes_models_summary(total: usize) -> String { + if is_chinese() { + if total == 0 { + "未配置模型".to_string() + } else { + format!("已配置 {total} 个模型") + } + } else if total == 0 { + "No models configured".to_string() + } else { + format!("{total} models configured") + } + } + + pub fn tui_hermes_models_open_hint() -> &'static str { + if is_chinese() { + "按 Enter 编辑 Hermes 模型列表" + } else { + "Press Enter to edit Hermes models" + } + } + + pub fn tui_hermes_models_editor_title() -> &'static str { + if is_chinese() { + "Hermes 模型列表" + } else { + "Hermes Models" + } + } + pub fn tui_toast_json_must_be_array() -> &'static str { if is_chinese() { "JSON 必须是数组" @@ -1772,6 +1851,14 @@ pub mod texts { } } + pub fn tui_toast_json_must_be_object_or_array() -> &'static str { + if is_chinese() { + "JSON 必须是对象或数组" + } else { + "JSON must be an object or array" + } + } + pub fn tui_label_opencode_model_id() -> &'static str { if is_chinese() { "主模型 ID" diff --git a/src-tauri/src/cli/tui/app/editor_state.rs b/src-tauri/src/cli/tui/app/editor_state.rs index e3480def..b908d1cc 100644 --- a/src-tauri/src/cli/tui/app/editor_state.rs +++ b/src-tauri/src/cli/tui/app/editor_state.rs @@ -17,6 +17,7 @@ pub enum EditorSubmit { id: String, }, ProviderFormApplyJson, + ProviderFormApplyHermesModels, ProviderFormApplyOpenClawModels, ProviderFormApplyCodexAuth, ProviderFormApplyCodexConfigToml, diff --git a/src-tauri/src/cli/tui/app/form_handlers/provider.rs b/src-tauri/src/cli/tui/app/form_handlers/provider.rs index 2d0f2338..6d13424e 100644 --- a/src-tauri/src/cli/tui/app/form_handlers/provider.rs +++ b/src-tauri/src/cli/tui/app/form_handlers/provider.rs @@ -247,6 +247,13 @@ impl App { }; Action::None } + ProviderAddField::HermesApiMode => { + let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { + return Action::None; + }; + provider.hermes_api_mode = provider.hermes_api_mode.next(); + Action::None + } ProviderAddField::OpenClawApiProtocol => { let Some(FormState::ProviderAdd(provider)) = self.form.as_mut() else { return Action::None; @@ -296,6 +303,23 @@ impl App { } Action::None } + ProviderAddField::HermesModels => { + if matches!(key.code, KeyCode::Enter) { + let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { + return Action::None; + }; + self.open_editor( + texts::tui_hermes_models_editor_title(), + EditorKind::Json, + provider.hermes_models_editor_text(), + EditorSubmit::ProviderFormApplyHermesModels, + ); + if let Some(editor) = self.editor.as_mut() { + editor.mode = EditorMode::Edit; + } + } + Action::None + } ProviderAddField::CommonSnippet => { if matches!(key.code, KeyCode::Enter) { let Some(FormState::ProviderAdd(provider)) = self.form.as_ref() else { @@ -307,7 +331,8 @@ impl App { } ProviderAddField::CodexModel | ProviderAddField::GeminiModel - | ProviderAddField::OpenCodeModelId => { + | ProviderAddField::OpenCodeModelId + | ProviderAddField::HermesModel => { self.handle_provider_model_field_activate(selected, key) } _ => { @@ -344,12 +369,15 @@ impl App { (!provider.opencode_api_key.value.trim().is_empty()) .then(|| provider.opencode_api_key.value.clone()) } + ProviderAddField::HermesModel => (!provider.hermes_api_key.value.trim().is_empty()) + .then(|| provider.hermes_api_key.value.clone()), _ => None, }; let base_url = match selected { ProviderAddField::CodexModel => provider.codex_base_url.value.clone(), ProviderAddField::GeminiModel => provider.gemini_base_url.value.clone(), ProviderAddField::OpenCodeModelId => provider.opencode_base_url.value.clone(), + ProviderAddField::HermesModel => provider.hermes_base_url.value.clone(), _ => String::new(), }; Action::ProviderModelFetch { diff --git a/src-tauri/src/cli/tui/app/helpers.rs b/src-tauri/src/cli/tui/app/helpers.rs index 0c8738c7..5e955e1c 100644 --- a/src-tauri/src/cli/tui/app/helpers.rs +++ b/src-tauri/src/cli/tui/app/helpers.rs @@ -1185,12 +1185,13 @@ pub(crate) fn app_type_picker_index(app_type: &AppType) -> usize { AppType::Codex => 1, AppType::Gemini => 2, AppType::OpenCode => 3, - AppType::OpenClaw => 4, + AppType::Hermes => 4, + AppType::OpenClaw => 5, } } pub(crate) fn four_app_picker_index(app_type: &AppType) -> usize { - app_type_picker_index(app_type).min(3) + app_type_picker_index(app_type).min(4) } pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { @@ -1198,7 +1199,8 @@ pub(crate) fn app_type_for_picker_index(index: usize) -> AppType { 1 => AppType::Codex, 2 => AppType::Gemini, 3 => AppType::OpenCode, - 4 => AppType::OpenClaw, + 4 => AppType::Hermes, + 5 => AppType::OpenClaw, _ => AppType::Claude, } } diff --git a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs index acb62ac7..4dc893e2 100644 --- a/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs +++ b/src-tauri/src/cli/tui/app/overlay_handlers/pickers.rs @@ -63,7 +63,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(5); Action::None } KeyCode::Enter => { @@ -276,7 +276,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(5); Action::None } KeyCode::Enter => { @@ -374,6 +374,9 @@ impl App { provider.mark_claude_model_config_touched(); } } + } else if field == ProviderAddField::HermesModel { + provider.hermes_model.set(selected_model.clone()); + provider.ensure_hermes_model_entry(&selected_model); } else if let Some(input_field) = provider.input_mut(field) { input_field.set(selected_model); } @@ -529,7 +532,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(3); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::OpenClaw)); Action::None } KeyCode::Char('x') | KeyCode::Char(' ') => { @@ -610,7 +613,7 @@ impl App { Action::None } KeyCode::Down => { - *selected = (*selected + 1).min(4); + *selected = (*selected + 1).min(app_type_picker_index(&AppType::OpenClaw)); Action::None } KeyCode::Char('x') | KeyCode::Char(' ') => { diff --git a/src-tauri/src/cli/tui/app/tests.rs b/src-tauri/src/cli/tui/app/tests.rs index 2eeefa89..b40978f8 100644 --- a/src-tauri/src/cli/tui/app/tests.rs +++ b/src-tauri/src/cli/tui/app/tests.rs @@ -366,7 +366,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::SkillsAppsPicker { selected, .. } if *selected == 3 + Overlay::SkillsAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char('x')), &data); @@ -374,11 +374,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::SkillsAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -440,6 +441,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -464,6 +466,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -504,6 +507,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -526,6 +530,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -552,6 +557,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -574,6 +580,7 @@ mod tests { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); @@ -2157,7 +2164,7 @@ mod tests { assert!(matches!(action, Action::None)); assert!(matches!( &app.overlay, - Overlay::McpAppsPicker { selected, .. } if *selected == 3 + Overlay::McpAppsPicker { selected, .. } if *selected == 4 )); let action = app.on_key(key(KeyCode::Char('x')), &data); @@ -2165,11 +2172,12 @@ mod tests { assert!(matches!( &app.overlay, Overlay::McpAppsPicker { selected, apps, .. } - if *selected == 3 + if *selected == 4 && !apps.claude && !apps.codex && !apps.gemini - && apps.opencode + && !apps.opencode + && apps.hermes )); } @@ -7850,6 +7858,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/data.rs b/src-tauri/src/cli/tui/data.rs index ff9e2af8..19e72a6a 100644 --- a/src-tauri/src/cli/tui/data.rs +++ b/src-tauri/src/cli/tui/data.rs @@ -250,6 +250,7 @@ impl ProxySnapshot { AppType::Codex => Some(self.codex_takeover), AppType::Gemini => Some(self.gemini_takeover), AppType::OpenCode => None, + AppType::Hermes => None, AppType::OpenClaw => None, } } @@ -640,6 +641,13 @@ fn extract_api_url(settings_config: &Value, app_type: &AppType) -> Option settings_config + .get("base_url") + .or_else(|| settings_config.get("baseUrl")) + .or_else(|| settings_config.get("baseURL")) + .or_else(|| settings_config.get("endpoint"))? + .as_str() + .map(|s| s.to_string()), AppType::OpenClaw => settings_config .get("baseUrl") .or_else(|| settings_config.get("base_url"))? @@ -654,6 +662,25 @@ fn extract_primary_model_id( openclaw_live_provider: Option<&Value>, ) -> Option { match app_type { + AppType::Hermes => settings_config + .get("model") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + settings_config + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + settings_config + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }), AppType::OpenClaw => match openclaw_live_provider { Some(live_provider) => openclaw_primary_model_id(live_provider), None => openclaw_primary_model_id(settings_config), diff --git a/src-tauri/src/cli/tui/form.rs b/src-tauri/src/cli/tui/form.rs index 28ebc01f..6495a920 100644 --- a/src-tauri/src/cli/tui/form.rs +++ b/src-tauri/src/cli/tui/form.rs @@ -33,6 +33,7 @@ pub const OPENCLAW_API_PROTOCOLS: [&str; 5] = [ "google-generative-ai", "bedrock-converse-stream", ]; +pub const HERMES_DEFAULT_API_MODE: &str = "chat_completions"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum GeminiAuthType { @@ -71,6 +72,49 @@ pub enum ClaudeApiFormat { OpenAiResponses, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HermesApiMode { + ChatCompletions, + CodexResponses, + AnthropicMessages, + BedrockConverse, +} + +impl HermesApiMode { + pub const ALL: [Self; 4] = [ + HermesApiMode::ChatCompletions, + HermesApiMode::CodexResponses, + HermesApiMode::AnthropicMessages, + HermesApiMode::BedrockConverse, + ]; + + pub fn as_str(self) -> &'static str { + match self { + HermesApiMode::ChatCompletions => "chat_completions", + HermesApiMode::CodexResponses => "codex_responses", + HermesApiMode::AnthropicMessages => "anthropic_messages", + HermesApiMode::BedrockConverse => "bedrock_converse", + } + } + + pub fn from_raw(value: &str) -> Self { + match value { + "chat_completions" | "openai_chat" | "openai_chat_completions" => { + HermesApiMode::ChatCompletions + } + "codex_responses" | "openai_responses" => HermesApiMode::CodexResponses, + "anthropic_messages" => HermesApiMode::AnthropicMessages, + "bedrock_converse" => HermesApiMode::BedrockConverse, + _ => HermesApiMode::ChatCompletions, + } + } + + pub fn next(self) -> Self { + let index = Self::ALL.iter().position(|item| *item == self).unwrap_or(0); + Self::ALL[(index + 1) % Self::ALL.len()] + } +} + impl ClaudeApiFormat { pub const ALL: [Self; 3] = [ ClaudeApiFormat::Anthropic, @@ -155,6 +199,11 @@ pub enum ProviderAddField { Name, WebsiteUrl, Notes, + HermesApiMode, + HermesBaseUrl, + HermesApiKey, + HermesModel, + HermesModels, ClaudeBaseUrl, ClaudeApiFormat, ClaudeApiKey, @@ -265,6 +314,12 @@ pub struct ProviderAddFormState { pub gemini_base_url: TextInput, pub gemini_model: TextInput, + pub hermes_api_mode: HermesApiMode, + pub hermes_api_key: TextInput, + pub hermes_base_url: TextInput, + pub hermes_model: TextInput, + pub hermes_models: Value, + pub openclaw_user_agent: bool, pub openclaw_models: Vec, pub opencode_npm_package: TextInput, diff --git a/src-tauri/src/cli/tui/form/provider_json.rs b/src-tauri/src/cli/tui/form/provider_json.rs index 349a205f..39fb8ac8 100644 --- a/src-tauri/src/cli/tui/form/provider_json.rs +++ b/src-tauri/src/cli/tui/form/provider_json.rs @@ -6,8 +6,8 @@ use super::codex_config::{ build_codex_provider_config_toml, clean_codex_provider_key, update_codex_config_snippet, }; use super::{ - ClaudeApiFormat, GeminiAuthType, ProviderAddFormState, OPENCLAW_DEFAULT_API_PROTOCOL, - OPENCLAW_DEFAULT_USER_AGENT, + ClaudeApiFormat, GeminiAuthType, ProviderAddFormState, HERMES_DEFAULT_API_MODE, + OPENCLAW_DEFAULT_API_PROTOCOL, OPENCLAW_DEFAULT_USER_AGENT, }; impl ProviderAddFormState { @@ -287,6 +287,51 @@ impl ProviderAddFormState { settings_obj.insert("models".to_string(), models_value); } } + AppType::Hermes => { + settings_obj.remove("apiKey"); + settings_obj.remove("baseUrl"); + settings_obj.remove("baseURL"); + settings_obj.remove("endpoint"); + settings_obj.remove("apiMode"); + + let provider_name = self.hermes_provider_name(); + if provider_name.is_empty() { + settings_obj.remove("name"); + } else { + settings_obj.insert("name".to_string(), json!(provider_name)); + } + + let source = settings_obj + .get("_cc_source") + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("custom_providers"); + settings_obj.insert("_cc_source".to_string(), json!(source)); + + settings_obj.insert( + "api_mode".to_string(), + json!(if self.hermes_api_mode.as_str().trim().is_empty() { + HERMES_DEFAULT_API_MODE + } else { + self.hermes_api_mode.as_str() + }), + ); + + set_or_remove_trimmed(settings_obj, "api_key", &self.hermes_api_key.value); + set_or_remove_trimmed(settings_obj, "base_url", &self.hermes_base_url.value); + set_or_remove_trimmed(settings_obj, "model", &self.hermes_model.value); + + let has_models = match &self.hermes_models { + Value::Object(map) => !map.is_empty(), + Value::Array(items) => !items.is_empty(), + _ => false, + }; + if has_models { + settings_obj.insert("models".to_string(), self.hermes_models.clone()); + } else { + settings_obj.remove("models"); + } + } AppType::OpenClaw => { settings_obj.remove("npm"); settings_obj.remove("options"); @@ -413,7 +458,10 @@ impl ProviderAddFormState { if snippet.is_empty() { return Ok(provider_value); } - if matches!(self.app_type, AppType::OpenCode | AppType::OpenClaw) { + if matches!( + self.app_type, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw + ) { return Ok(provider_value); } @@ -504,10 +552,14 @@ impl ProviderAddFormState { } fn should_write_common_config_meta(&self) -> bool { - matches!(self.app_type, AppType::OpenClaw) - || !self.mode.is_edit() - || self.include_common_config_touched - || self.has_common_config_meta() + let supports_common_config_meta = matches!( + self.app_type, + AppType::Claude | AppType::Codex | AppType::Gemini | AppType::OpenClaw + ); + supports_common_config_meta + && (!self.mode.is_edit() + || self.include_common_config_touched + || self.has_common_config_meta()) } fn has_common_config_meta(&self) -> bool { @@ -567,7 +619,7 @@ pub(crate) fn strip_common_config_from_settings( ) .map_err(|e| e.to_string())?; } - AppType::OpenCode | AppType::OpenClaw => {} + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => {} AppType::Codex => { *settings_value = ProviderService::remove_common_config_from_settings_for_preview( app_type, diff --git a/src-tauri/src/cli/tui/form/provider_state.rs b/src-tauri/src/cli/tui/form/provider_state.rs index a3176dce..ee52863a 100644 --- a/src-tauri/src/cli/tui/form/provider_state.rs +++ b/src-tauri/src/cli/tui/form/provider_state.rs @@ -9,12 +9,13 @@ use super::provider_json::{ use super::provider_state_loading::populate_form_from_provider; use super::{ ClaudeApiFormat, CodexPreviewSection, CodexWireApi, FormFocus, FormMode, GeminiAuthType, - ProviderAddField, ProviderAddFormState, TextInput, OPENCLAW_DEFAULT_API_PROTOCOL, + HermesApiMode, ProviderAddField, ProviderAddFormState, TextInput, HERMES_DEFAULT_API_MODE, + OPENCLAW_DEFAULT_API_PROTOCOL, }; impl ProviderAddFormState { pub fn new(app_type: AppType) -> Self { - let include_common_config = !matches!(app_type, AppType::OpenClaw); + let include_common_config = !matches!(app_type, AppType::OpenClaw | AppType::Hermes); let openclaw_api_default = match app_type { AppType::OpenClaw => OPENCLAW_DEFAULT_API_PROTOCOL, _ => "@ai-sdk/openai-compatible", @@ -65,6 +66,11 @@ impl ProviderAddFormState { gemini_api_key: TextInput::new(""), gemini_base_url: TextInput::new("https://generativelanguage.googleapis.com"), gemini_model: TextInput::new(""), + hermes_api_mode: HermesApiMode::from_raw(HERMES_DEFAULT_API_MODE), + hermes_api_key: TextInput::new(""), + hermes_base_url: TextInput::new(""), + hermes_model: TextInput::new(""), + hermes_models: json!({}), openclaw_user_agent: false, openclaw_models: Vec::new(), opencode_npm_package: TextInput::new(openclaw_api_default), @@ -102,9 +108,9 @@ impl ProviderAddFormState { .meta .as_ref() .and_then(|meta| meta.apply_common_config) - .unwrap_or(!matches!(app_type, AppType::OpenClaw)); + .unwrap_or(!matches!(app_type, AppType::OpenClaw | AppType::Hermes)); - if matches!(app_type, AppType::OpenClaw) { + if matches!(app_type, AppType::OpenClaw | AppType::Hermes) { form.include_common_config = false; } @@ -149,7 +155,7 @@ impl ProviderAddFormState { ProviderAddField::Notes, ]; - if matches!(self.app_type, AppType::OpenClaw) { + if matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) { fields.insert(0, ProviderAddField::Id); } @@ -187,6 +193,13 @@ impl ProviderAddFormState { fields.push(ProviderAddField::OpenCodeModelContextLimit); fields.push(ProviderAddField::OpenCodeModelOutputLimit); } + AppType::Hermes => { + fields.push(ProviderAddField::HermesApiMode); + fields.push(ProviderAddField::HermesBaseUrl); + fields.push(ProviderAddField::HermesApiKey); + fields.push(ProviderAddField::HermesModel); + fields.push(ProviderAddField::HermesModels); + } AppType::OpenClaw => { fields.push(ProviderAddField::OpenClawApiProtocol); fields.push(ProviderAddField::OpenCodeApiKey); @@ -196,7 +209,7 @@ impl ProviderAddFormState { } } - if !matches!(self.app_type, AppType::OpenClaw) { + if !matches!(self.app_type, AppType::OpenClaw | AppType::Hermes) { fields.push(ProviderAddField::CommonConfigDivider); fields.push(ProviderAddField::CommonSnippet); fields.push(ProviderAddField::IncludeCommonConfig); @@ -210,6 +223,9 @@ impl ProviderAddFormState { ProviderAddField::Name => Some(&self.name), ProviderAddField::WebsiteUrl => Some(&self.website_url), ProviderAddField::Notes => Some(&self.notes), + ProviderAddField::HermesBaseUrl => Some(&self.hermes_base_url), + ProviderAddField::HermesApiKey => Some(&self.hermes_api_key), + ProviderAddField::HermesModel => Some(&self.hermes_model), ProviderAddField::ClaudeBaseUrl => Some(&self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&self.codex_base_url), @@ -232,6 +248,8 @@ impl ProviderAddFormState { | ProviderAddField::ClaudeModelConfig | ProviderAddField::ClaudeHideAttribution | ProviderAddField::GeminiAuthType + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels @@ -247,6 +265,9 @@ impl ProviderAddFormState { ProviderAddField::Name => Some(&mut self.name), ProviderAddField::WebsiteUrl => Some(&mut self.website_url), ProviderAddField::Notes => Some(&mut self.notes), + ProviderAddField::HermesBaseUrl => Some(&mut self.hermes_base_url), + ProviderAddField::HermesApiKey => Some(&mut self.hermes_api_key), + ProviderAddField::HermesModel => Some(&mut self.hermes_model), ProviderAddField::ClaudeBaseUrl => Some(&mut self.claude_base_url), ProviderAddField::ClaudeApiKey => Some(&mut self.claude_api_key), ProviderAddField::CodexBaseUrl => Some(&mut self.codex_base_url), @@ -273,6 +294,8 @@ impl ProviderAddFormState { | ProviderAddField::ClaudeModelConfig | ProviderAddField::ClaudeHideAttribution | ProviderAddField::GeminiAuthType + | ProviderAddField::HermesApiMode + | ProviderAddField::HermesModels | ProviderAddField::OpenClawApiProtocol | ProviderAddField::OpenClawUserAgent | ProviderAddField::OpenClawModels @@ -326,6 +349,20 @@ impl ProviderAddFormState { self.claude_hide_attribution_touched = true; } + pub(crate) fn hermes_provider_name(&self) -> String { + let id = self.id.value.trim(); + if !id.is_empty() { + return id.to_string(); + } + + let name = self.name.value.trim(); + if !name.is_empty() { + return name.to_string(); + } + + String::new() + } + pub fn is_claude_official_provider(&self) -> bool { if !matches!(self.app_type, AppType::Claude) { return false; @@ -544,6 +581,56 @@ impl ProviderAddFormState { } } + pub(crate) fn hermes_models_summary(&self) -> String { + let total = match &self.hermes_models { + Value::Array(items) => items.len(), + Value::Object(items) => items.len(), + _ => 0, + }; + texts::tui_hermes_models_summary(total) + } + + pub(crate) fn hermes_models_editor_text(&self) -> String { + serde_json::to_string_pretty(&self.hermes_models).unwrap_or_else(|_| "{}".to_string()) + } + + pub(crate) fn ensure_hermes_model_entry(&mut self, selected_model: &str) { + let selected_model = selected_model.trim(); + if selected_model.is_empty() { + return; + } + + if !self.hermes_models.is_object() { + self.hermes_models = json!({}); + } + + if let Some(models) = self.hermes_models.as_object_mut() { + models + .entry(selected_model.to_string()) + .or_insert_with(|| json!({ "name": selected_model })); + } + } + + pub fn apply_hermes_models_value(&mut self, models_value: Value) -> Result<(), String> { + if !matches!(self.app_type, AppType::Hermes) { + return Ok(()); + } + if !models_value.is_array() && !models_value.is_object() { + return Err(texts::tui_toast_json_must_be_object_or_array().to_string()); + } + + let mut provider_value = self.to_provider_json_value(); + let settings_value = provider_value + .as_object_mut() + .and_then(|obj| obj.get_mut("settingsConfig")) + .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; + let settings_obj = settings_value + .as_object_mut() + .ok_or_else(|| texts::tui_toast_json_must_be_object().to_string())?; + settings_obj.insert("models".to_string(), models_value); + self.apply_provider_json_value_to_fields(provider_value) + } + pub(crate) fn openclaw_models_summary(&self) -> String { let total = self.openclaw_models.len(); texts::tui_openclaw_models_summary(total) diff --git a/src-tauri/src/cli/tui/form/provider_state_loading.rs b/src-tauri/src/cli/tui/form/provider_state_loading.rs index 49f19d37..06014986 100644 --- a/src-tauri/src/cli/tui/form/provider_state_loading.rs +++ b/src-tauri/src/cli/tui/form/provider_state_loading.rs @@ -4,8 +4,8 @@ use serde_json::Value; use super::codex_config::parse_codex_config_snippet; use super::{ - claude_hide_attribution_enabled, ClaudeApiFormat, ProviderAddFormState, - OPENCLAW_DEFAULT_API_PROTOCOL, + claude_hide_attribution_enabled, ClaudeApiFormat, HermesApiMode, ProviderAddFormState, + HERMES_DEFAULT_API_MODE, OPENCLAW_DEFAULT_API_PROTOCOL, }; pub(super) fn populate_form_from_provider( @@ -18,6 +18,7 @@ pub(super) fn populate_form_from_provider( AppType::Codex => populate_codex_form(form, provider), AppType::Gemini => populate_gemini_form(form, provider), AppType::OpenCode => populate_opencode_form(form, provider), + AppType::Hermes => populate_hermes_form(form, provider), AppType::OpenClaw => populate_openclaw_form(form, provider), } } @@ -198,6 +199,70 @@ fn populate_opencode_form(form: &mut ProviderAddFormState, provider: &Provider) } } +fn populate_hermes_form(form: &mut ProviderAddFormState, provider: &Provider) { + form.hermes_api_mode = provider + .settings_config + .get("api_mode") + .or_else(|| provider.settings_config.get("apiMode")) + .and_then(|value| value.as_str()) + .map(HermesApiMode::from_raw) + .unwrap_or_else(|| HermesApiMode::from_raw(HERMES_DEFAULT_API_MODE)); + + if let Some(api_key) = provider + .settings_config + .get("api_key") + .or_else(|| provider.settings_config.get("apiKey")) + .and_then(|value| value.as_str()) + { + form.hermes_api_key.set(api_key); + } + + if let Some(base_url) = provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|value| value.as_str()) + { + form.hermes_base_url.set(base_url); + } + + if let Some(model) = provider + .settings_config + .get("model") + .and_then(|value| value.as_str()) + { + form.hermes_model.set(model); + } + + form.hermes_models = provider + .settings_config + .get("models") + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + + if form.hermes_model.is_blank() { + match &form.hermes_models { + Value::Object(models) => { + if let Some(model_id) = models.keys().next() { + form.hermes_model.set(model_id); + } + } + Value::Array(models) => { + if let Some(model_id) = models + .first() + .and_then(|model| model.get("id")) + .and_then(|value| value.as_str()) + { + form.hermes_model.set(model_id); + } + } + _ => {} + } + } +} + fn populate_openclaw_form(form: &mut ProviderAddFormState, provider: &Provider) { if let Some(api_key) = provider .settings_config diff --git a/src-tauri/src/cli/tui/form/provider_templates.rs b/src-tauri/src/cli/tui/form/provider_templates.rs index 0bfbf50a..374d958b 100644 --- a/src-tauri/src/cli/tui/form/provider_templates.rs +++ b/src-tauri/src/cli/tui/form/provider_templates.rs @@ -157,17 +157,25 @@ static PROVIDER_TEMPLATE_DEFS_OPENCODE: [ProviderTemplateDef; 1] = [ProviderTemp label: "Custom", }]; +static PROVIDER_TEMPLATE_DEFS_HERMES: [ProviderTemplateDef; 1] = [ProviderTemplateDef { + id: ProviderTemplateId::Custom, + label: "Custom", +}]; + static PROVIDER_TEMPLATE_DEFS_OPENCLAW: [ProviderTemplateDef; 1] = [ProviderTemplateDef { id: ProviderTemplateId::Custom, label: "Custom", }]; +static SPONSOR_PROVIDER_PRESETS_HERMES: [SponsorProviderPreset; 0] = []; + pub(super) fn provider_builtin_template_defs(app_type: &AppType) -> &'static [ProviderTemplateDef] { match app_type { AppType::Claude => &PROVIDER_TEMPLATE_DEFS_CLAUDE, AppType::Codex => &PROVIDER_TEMPLATE_DEFS_CODEX, AppType::Gemini => &PROVIDER_TEMPLATE_DEFS_GEMINI, AppType::OpenCode => &PROVIDER_TEMPLATE_DEFS_OPENCODE, + AppType::Hermes => &PROVIDER_TEMPLATE_DEFS_HERMES, AppType::OpenClaw => &PROVIDER_TEMPLATE_DEFS_OPENCLAW, } } @@ -178,6 +186,7 @@ pub(super) fn provider_sponsor_presets(app_type: &AppType) -> &'static [SponsorP AppType::Codex => &SPONSOR_PROVIDER_PRESETS_CODEX, AppType::Gemini => &SPONSOR_PROVIDER_PRESETS_GEMINI, AppType::OpenCode => &SPONSOR_PROVIDER_PRESETS_OPENCODE, + AppType::Hermes => &SPONSOR_PROVIDER_PRESETS_HERMES, AppType::OpenClaw => &SPONSOR_PROVIDER_PRESETS_OPENCLAW, } } @@ -253,6 +262,11 @@ impl ProviderAddFormState { self.gemini_api_key = defaults.gemini_api_key; self.gemini_base_url = defaults.gemini_base_url; self.gemini_model = defaults.gemini_model; + self.hermes_api_mode = defaults.hermes_api_mode; + self.hermes_api_key = defaults.hermes_api_key; + self.hermes_base_url = defaults.hermes_base_url; + self.hermes_model = defaults.hermes_model; + self.hermes_models = defaults.hermes_models; self.openclaw_user_agent = defaults.openclaw_user_agent; self.openclaw_models = defaults.openclaw_models; self.opencode_npm_package = defaults.opencode_npm_package; @@ -378,6 +392,7 @@ impl ProviderAddFormState { self.opencode_model_original_id = Some("claude-opus-4.6".to_string()); } } + AppType::Hermes => {} AppType::OpenClaw => { if preset.id == "aicodemirror" { self.opencode_api_key.set(""); diff --git a/src-tauri/src/cli/tui/runtime_actions/editor.rs b/src-tauri/src/cli/tui/runtime_actions/editor.rs index 8e0532ad..4050fda2 100644 --- a/src-tauri/src/cli/tui/runtime_actions/editor.rs +++ b/src-tauri/src/cli/tui/runtime_actions/editor.rs @@ -84,6 +84,9 @@ pub(super) fn submit( } => submit_prompt_create(ctx, id, name, description, content), EditorSubmit::PromptEdit { id } => submit_prompt_edit(ctx, id, content), EditorSubmit::ProviderFormApplyJson => submit_provider_form_apply_json(ctx, content), + EditorSubmit::ProviderFormApplyHermesModels => { + submit_provider_form_apply_hermes_models(ctx, content) + } EditorSubmit::ProviderFormApplyOpenClawModels => { submit_provider_form_apply_openclaw_models(ctx, content) } @@ -371,6 +374,17 @@ fn submit_provider_form_apply_json( let mut provider_value = form.to_provider_json_value(); if let Some(obj) = provider_value.as_object_mut() { obj.insert("settingsConfig".to_string(), settings_value.clone()); + if matches!(form.app_type, AppType::Hermes) { + if let Some(name) = settings_value + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + obj.insert("id".to_string(), json!(name)); + obj.insert("name".to_string(), json!(name)); + } + } } Some(provider_value) } @@ -429,6 +443,43 @@ fn submit_provider_form_apply_openclaw_models( Ok(()) } +fn submit_provider_form_apply_hermes_models( + ctx: &mut RuntimeActionContext<'_>, + content: String, +) -> Result<(), AppError> { + let models_value: Value = match serde_json::from_str(&content) { + Ok(value) => value, + Err(e) => { + ctx.app.push_toast( + texts::tui_toast_invalid_json(&e.to_string()), + ToastKind::Error, + ); + return Ok(()); + } + }; + + if !models_value.is_array() && !models_value.is_object() { + ctx.app.push_toast( + texts::tui_toast_json_must_be_object_or_array(), + ToastKind::Error, + ); + return Ok(()); + } + + let apply_result = match ctx.app.form.as_mut() { + Some(FormState::ProviderAdd(form)) => form.apply_hermes_models_value(models_value), + _ => Ok(()), + }; + + if let Err(err) = apply_result { + ctx.app.push_toast(err, ToastKind::Error); + return Ok(()); + } + + ctx.app.editor = None; + Ok(()) +} + fn submit_provider_form_apply_codex_auth( ctx: &mut RuntimeActionContext<'_>, content: String, diff --git a/src-tauri/src/cli/tui/runtime_actions/helpers.rs b/src-tauri/src/cli/tui/runtime_actions/helpers.rs index 5f9e8061..d5c131f8 100644 --- a/src-tauri/src/cli/tui/runtime_actions/helpers.rs +++ b/src-tauri/src/cli/tui/runtime_actions/helpers.rs @@ -42,6 +42,7 @@ pub(crate) fn import_mcp_for_current_app(app: &mut App, data: &mut UiData) -> Re AppType::Codex => McpService::import_from_codex(&state), AppType::Gemini => McpService::import_from_gemini(&state), AppType::OpenCode => McpService::import_from_opencode(&state), + AppType::Hermes => Ok(0), AppType::OpenClaw => Ok(0), } }, @@ -68,6 +69,7 @@ pub(crate) fn app_display_name(app_type: &AppType) -> &'static str { AppType::Codex => "Codex", AppType::Gemini => "Gemini", AppType::OpenCode => "OpenCode", + AppType::Hermes => "Hermes", AppType::OpenClaw => "OpenClaw", } } diff --git a/src-tauri/src/cli/tui/runtime_actions/mod.rs b/src-tauri/src/cli/tui/runtime_actions/mod.rs index aaae1917..047f0ca8 100644 --- a/src-tauri/src/cli/tui/runtime_actions/mod.rs +++ b/src-tauri/src/cli/tui/runtime_actions/mod.rs @@ -544,6 +544,7 @@ mod tests { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -553,6 +554,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::OpenClaw)); @@ -612,6 +614,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -633,6 +636,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, @@ -660,6 +664,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save initial visible apps"); @@ -670,6 +675,7 @@ mod tests { codex: false, gemini: false, opencode: true, + hermes: false, openclaw: false, }; let mut app = App::new(Some(AppType::Claude)); @@ -706,6 +712,7 @@ mod tests { codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }; crate::settings::set_visible_apps(initial_visible_apps.clone()) @@ -724,6 +731,7 @@ mod tests { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }, diff --git a/src-tauri/src/cli/tui/tests.rs b/src-tauri/src/cli/tui/tests.rs index d54b3e22..88418444 100644 --- a/src-tauri/src/cli/tui/tests.rs +++ b/src-tauri/src/cli/tui/tests.rs @@ -643,6 +643,7 @@ fn startup_hidden_requested_app_bootstrap_uses_visible_app_normalization_before_ codex: true, gemini: false, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); diff --git a/src-tauri/src/cli/tui/theme.rs b/src-tauri/src/cli/tui/theme.rs index 69131619..79b5c7a9 100644 --- a/src-tauri/src/cli/tui/theme.rs +++ b/src-tauri/src/cli/tui/theme.rs @@ -10,6 +10,7 @@ const DRACULA_PINK: (u8, u8, u8) = (255, 121, 198); const DRACULA_ORANGE: (u8, u8, u8) = (255, 184, 108); const DRACULA_YELLOW: (u8, u8, u8) = (241, 250, 140); const DRACULA_RED: (u8, u8, u8) = (255, 85, 85); +const HERMES_TEAL: (u8, u8, u8) = (0, 109, 112); const OPENCLAW_CORAL: (u8, u8, u8) = (255, 79, 64); const DRACULA_COMMENT: (u8, u8, u8) = (98, 114, 164); const DRACULA_SURFACE: (u8, u8, u8) = (68, 71, 90); @@ -183,6 +184,7 @@ fn accent_rgb(app: &AppType) -> (u8, u8, u8) { AppType::Claude => DRACULA_CYAN, AppType::Gemini => DRACULA_PINK, AppType::OpenCode => DRACULA_ORANGE, + AppType::Hermes => HERMES_TEAL, AppType::OpenClaw => OPENCLAW_CORAL, } } @@ -275,6 +277,21 @@ mod tests { assert_ne!(openclaw.accent, codex.accent); } + #[test] + fn hermes_theme_uses_teal_accent() { + let _lock = env_lock().lock().expect("env lock poisoned"); + let _no_color = EnvGuard::remove("NO_COLOR"); + let _color_mode = EnvGuard::remove(COLOR_MODE_ENV); + let _colorterm = EnvGuard::set("COLORTERM", "truecolor"); + let _term = EnvGuard::set("TERM", "xterm-256color"); + + let hermes = theme_for(&AppType::Hermes); + let opencode = theme_for(&AppType::OpenCode); + + assert_eq!(hermes.accent, Color::Rgb(0, 109, 112)); + assert_ne!(hermes.accent, opencode.accent); + } + #[test] fn theme_keeps_rgb_colors_when_truecolor_is_available() { let _lock = env_lock().lock().expect("env lock poisoned"); diff --git a/src-tauri/src/cli/tui/ui/forms/provider.rs b/src-tauri/src/cli/tui/ui/forms/provider.rs index d71bbd88..94ce3b94 100644 --- a/src-tauri/src/cli/tui/ui/forms/provider.rs +++ b/src-tauri/src/cli/tui/ui/forms/provider.rs @@ -4,12 +4,19 @@ fn claude_api_format_label(api_format: crate::cli::tui::form::ClaudeApiFormat) - texts::tui_claude_api_format_value(api_format.as_str()).to_string() } +fn hermes_api_mode_label(api_mode: crate::cli::tui::form::HermesApiMode) -> String { + texts::tui_hermes_api_mode_value(api_mode.as_str()).to_string() +} + fn should_redact_provider_field( provider: &super::form::ProviderAddFormState, field: ProviderAddField, ) -> bool { - matches!(provider.app_type, AppType::OpenClaw) - && matches!(field, ProviderAddField::OpenCodeApiKey) + matches!( + (&provider.app_type, field), + (&AppType::OpenClaw, ProviderAddField::OpenCodeApiKey) + | (&AppType::Hermes, ProviderAddField::HermesApiKey) + ) } pub(crate) fn render_provider_add_form( @@ -300,6 +307,11 @@ pub(crate) fn provider_field_label_and_value( strip_trailing_colon(texts::website_url_label()).to_string() } ProviderAddField::Notes => strip_trailing_colon(texts::notes_label()).to_string(), + ProviderAddField::HermesApiMode => texts::tui_label_hermes_api_mode().to_string(), + ProviderAddField::HermesBaseUrl => texts::tui_label_base_url().to_string(), + ProviderAddField::HermesApiKey => texts::tui_label_api_key().to_string(), + ProviderAddField::HermesModel => texts::model_label().to_string(), + ProviderAddField::HermesModels => texts::tui_label_hermes_models().to_string(), ProviderAddField::ClaudeBaseUrl => texts::tui_label_base_url().to_string(), ProviderAddField::ClaudeApiFormat => texts::tui_label_claude_api_format().to_string(), ProviderAddField::ClaudeApiKey => texts::tui_label_api_key().to_string(), @@ -347,6 +359,8 @@ pub(crate) fn provider_field_label_and_value( }; let value = match field { + ProviderAddField::HermesApiMode => hermes_api_mode_label(provider.hermes_api_mode), + ProviderAddField::HermesModels => provider.hermes_models_summary(), ProviderAddField::ClaudeApiFormat => claude_api_format_label(provider.claude_api_format), ProviderAddField::CodexWireApi => provider.codex_wire_api.as_str().to_string(), ProviderAddField::CodexRequiresOpenaiAuth => { @@ -469,6 +483,10 @@ pub(crate) fn provider_field_editor_line( ProviderAddField::GeminiAuthType => { format!("auth_type = {}", provider.gemini_auth_type.as_str()) } + ProviderAddField::HermesApiMode => { + format!("api_mode = {}", provider.hermes_api_mode.as_str()) + } + ProviderAddField::HermesModels => texts::tui_hermes_models_open_hint().to_string(), ProviderAddField::OpenClawApiProtocol => { format!("api = {}", provider.opencode_npm_package.value.trim()) } diff --git a/src-tauri/src/cli/tui/ui/forms/shared.rs b/src-tauri/src/cli/tui/ui/forms/shared.rs index 0973e453..56723661 100644 --- a/src-tauri/src/cli/tui/ui/forms/shared.rs +++ b/src-tauri/src/cli/tui/ui/forms/shared.rs @@ -34,19 +34,23 @@ pub(crate) fn add_form_key_items( ]); } else { let enter_action = match selected_field { - Some(ProviderAddField::CodexModel | ProviderAddField::GeminiModel) => { - texts::tui_key_fetch_model() - } + Some( + ProviderAddField::CodexModel + | ProviderAddField::GeminiModel + | ProviderAddField::HermesModel, + ) => texts::tui_key_fetch_model(), Some( ProviderAddField::ClaudeModelConfig | ProviderAddField::CommonSnippet - | ProviderAddField::OpenClawModels, + | ProviderAddField::OpenClawModels + | ProviderAddField::HermesModels, ) => texts::tui_key_open(), Some( ProviderAddField::GeminiAuthType | ProviderAddField::ClaudeHideAttribution | ProviderAddField::OpenClawApiProtocol - | ProviderAddField::OpenClawUserAgent, + | ProviderAddField::OpenClawUserAgent + | ProviderAddField::HermesApiMode, ) => texts::tui_key_toggle(), _ => texts::tui_key_edit_mode(), }; diff --git a/src-tauri/src/cli/tui/ui/header_tests.rs b/src-tauri/src/cli/tui/ui/header_tests.rs index 60c5685b..c40ef21b 100644 --- a/src-tauri/src/cli/tui/ui/header_tests.rs +++ b/src-tauri/src/cli/tui/ui/header_tests.rs @@ -194,6 +194,7 @@ fn header_openclaw_sacrifices_tabs_before_losing_the_only_status_badge() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); @@ -233,6 +234,7 @@ fn header_openclaw_truncates_long_default_model_without_fake_proxy_gap() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }); let _lang = use_test_language(Language::English); diff --git a/src-tauri/src/cli/tui/ui/overlay/pickers.rs b/src-tauri/src/cli/tui/ui/overlay/pickers.rs index 211ded6a..e0acc96d 100644 --- a/src-tauri/src/cli/tui/ui/overlay/pickers.rs +++ b/src-tauri/src/cli/tui/ui/overlay/pickers.rs @@ -706,6 +706,7 @@ pub(super) fn render_mcp_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } @@ -780,6 +781,7 @@ pub(super) fn render_visible_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, crate::app_config::AppType::OpenClaw, ], ); @@ -805,6 +807,7 @@ pub(super) fn render_skills_apps_picker_overlay( crate::app_config::AppType::Codex, crate::app_config::AppType::Gemini, crate::app_config::AppType::OpenCode, + crate::app_config::AppType::Hermes, ], ); } diff --git a/src-tauri/src/cli/tui/ui/tests.rs b/src-tauri/src/cli/tui/ui/tests.rs index 079fa1c2..5a63f768 100644 --- a/src-tauri/src/cli/tui/ui/tests.rs +++ b/src-tauri/src/cli/tui/ui/tests.rs @@ -575,6 +575,7 @@ fn installed_skill(directory: &str, name: &str) -> InstalledSkill { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 1, } @@ -757,6 +758,7 @@ fn header_only_renders_selected_visible_apps() { codex: true, gemini: false, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -785,6 +787,7 @@ fn header_keeps_all_app_tabs_visible_with_proxy_chip() { codex: true, gemini: true, opencode: true, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -813,6 +816,7 @@ fn settings_page_shows_visible_apps_row_value() { codex: false, gemini: true, opencode: false, + hermes: false, openclaw: true, }) .expect("save visible apps"); @@ -893,6 +897,7 @@ fn zero_selection_warning_toast_renders_after_picker_rejection() { codex: false, gemini: false, opencode: false, + hermes: false, openclaw: false, }, }; @@ -980,6 +985,8 @@ fn openclaw_agents_picker_overlay_marks_current_option_when_editing_existing_fal fn header_centers_tabs_when_room_allows() { let _lock = lock_env(); let _no_color = EnvGuard::remove("NO_COLOR"); + let temp_home = TempDir::new().expect("create temp home"); + let _home = SettingsEnvGuard::set_home(temp_home.path()); let app = App::new(Some(AppType::Claude)); let buf = render_with_size(&app, &minimal_data(&app.app_type), 140, 40); @@ -1479,6 +1486,7 @@ fn home_connection_card_labels_mcp_and_skills_with_active_counts() { codex: false, gemini: false, opencode: false, + hermes: false, }, installed_at: 0, }, @@ -2289,6 +2297,7 @@ fn skills_page_shows_opencode_summary() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; @@ -2316,6 +2325,7 @@ fn skill_detail_page_shows_opencode_enabled_state() { codex: false, gemini: false, opencode: true, + hermes: false, }; data.skills.installed = vec![skill]; diff --git a/src-tauri/src/cli/ui/colors.rs b/src-tauri/src/cli/ui/colors.rs index 81d6d692..9a0744a5 100644 --- a/src-tauri/src/cli/ui/colors.rs +++ b/src-tauri/src/cli/ui/colors.rs @@ -34,6 +34,7 @@ fn inquire_color_for_app(app_type: &AppType) -> InquireColor { AppType::Claude => InquireColor::LightCyan, AppType::Gemini => InquireColor::LightMagenta, AppType::OpenCode => InquireColor::LightGreen, + AppType::Hermes => InquireColor::LightYellow, AppType::OpenClaw => InquireColor::LightRed, } } @@ -85,6 +86,7 @@ fn highlight_color_for_app(app_type: &AppType) -> Color { AppType::Claude => Color::BrightCyan, AppType::Gemini => Color::BrightMagenta, AppType::OpenCode => Color::BrightGreen, + AppType::Hermes => Color::BrightYellow, AppType::OpenClaw => Color::BrightRed, } } diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs index 2254cb06..69a5e7ea 100644 --- a/src-tauri/src/database/dao/skills.rs +++ b/src-tauri/src/database/dao/skills.rs @@ -43,6 +43,7 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: false, }, installed_at: row.get(12)?, }) @@ -83,6 +84,7 @@ impl Database { codex: row.get(9)?, gemini: row.get(10)?, opencode: row.get(11)?, + hermes: false, }, installed_at: row.get(12)?, }) diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs index af049eb3..4bc9c473 100644 --- a/src-tauri/src/deeplink/provider.rs +++ b/src-tauri/src/deeplink/provider.rs @@ -67,6 +67,7 @@ pub fn import_provider_from_deeplink( Some("codex") => Some("https://openai.com".to_string()), Some("gemini") => Some("https://ai.google.dev".to_string()), Some("opencode") => Some("https://opencode.ai".to_string()), + Some("hermes") => Some("https://hermes.sh".to_string()), _ => None, }; } @@ -139,6 +140,7 @@ fn build_provider_from_request( AppType::Codex => build_codex_settings(request), AppType::Gemini => build_gemini_settings(request), AppType::OpenCode => build_opencode_settings(request), + AppType::Hermes => build_hermes_settings(request), AppType::OpenClaw => build_openclaw_settings(request), }; @@ -328,6 +330,31 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value }) } +fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value { + let endpoint = get_primary_endpoint(request); + let mut settings = serde_json::Map::new(); + settings.insert( + "name".to_string(), + json!(request.name.clone().unwrap_or_else(|| "custom".to_string())), + ); + + if !endpoint.is_empty() { + settings.insert("baseUrl".to_string(), json!(endpoint)); + } + if let Some(api_key) = &request.api_key { + settings.insert("apiKey".to_string(), json!(api_key)); + } + if let Some(model) = request + .model + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + settings.insert("model".to_string(), json!(model)); + } + + Value::Object(settings) +} + fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value { if let Some(config) = &request.openclaw_config { let mut settings = match config { @@ -433,6 +460,7 @@ pub fn parse_and_merge_config( "codex" => merge_codex_config(&mut merged, &config_value)?, "gemini" => merge_gemini_config(&mut merged, &config_value)?, "opencode" => merge_additive_config(&mut merged, &config_value)?, + "hermes" => merge_additive_config(&mut merged, &config_value)?, "openclaw" => merge_openclaw_config(&mut merged, &config_value)?, "" => return Ok(merged), other => return Err(AppError::InvalidInput(format!("Invalid app type: {other}"))), diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs new file mode 100644 index 00000000..554cd55b --- /dev/null +++ b/src-tauri/src/hermes_config.rs @@ -0,0 +1,307 @@ +use crate::config::{atomic_write, home_dir}; +use crate::error::AppError; +use crate::settings::get_hermes_override_dir; +use indexmap::IndexMap; +use serde_json::{json, Map, Value}; +use std::fs; +use std::path::PathBuf; + +fn default_config() -> Value { + json!({ + "custom_providers": [] + }) +} + +pub fn get_hermes_dir() -> PathBuf { + if let Some(override_dir) = get_hermes_override_dir() { + return override_dir; + } + + home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + +pub fn get_hermes_config_path() -> PathBuf { + get_hermes_dir().join("config.yaml") +} + +pub fn read_hermes_config_source() -> Result, AppError> { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(None); + } + + fs::read_to_string(&path) + .map(Some) + .map_err(|e| AppError::io(&path, e)) +} + +pub fn write_hermes_config_source(source: &str) -> Result<(), AppError> { + let path = get_hermes_config_path(); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + atomic_write(&path, source.as_bytes()) +} + +pub fn read_hermes_config() -> Result { + let path = get_hermes_config_path(); + if !path.exists() { + return Ok(default_config()); + } + + let source = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?; + let yaml_value: serde_yaml::Value = serde_yaml::from_str(&source) + .map_err(|e| AppError::Config(format!("Failed to parse Hermes config as YAML: {e}")))?; + serde_json::to_value(yaml_value).map_err(|e| AppError::JsonSerialize { source: e }) +} + +fn write_hermes_config(config: &Value) -> Result<(), AppError> { + let yaml_value = serde_yaml::to_value(config) + .map_err(|e| AppError::Config(format!("Failed to convert Hermes config to YAML: {e}")))?; + let yaml = serde_yaml::to_string(&yaml_value) + .map_err(|e| AppError::Config(format!("Failed to serialize Hermes config as YAML: {e}")))?; + write_hermes_config_source(&yaml) +} + +fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = Value::Object(Map::new()); + } + value + .as_object_mut() + .expect("value should be object after normalization") +} + +fn provider_id_from_value(value: &Value) -> Option { + let object = value.as_object()?; + for key in ["name", "id", "provider"] { + let candidate = object + .get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(id) = candidate { + return Some(id.to_string()); + } + } + None +} + +fn primary_model_id_from_value(value: &Value) -> Option { + value + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| { + value + .get("models") + .and_then(Value::as_object) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + value + .get("models") + .and_then(Value::as_array) + .and_then(|models| models.first()) + .and_then(|model| model.get("id")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn provider_matches_model(provider: &Value, model_id: &str) -> bool { + let model_id = model_id.trim(); + if model_id.is_empty() { + return false; + } + + provider + .get("model") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + || provider + .get("models") + .and_then(Value::as_object) + .is_some_and(|models| models.contains_key(model_id)) + || provider + .get("models") + .and_then(Value::as_array) + .is_some_and(|models| { + models.iter().any(|model| { + model + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| value == model_id) + }) + }) +} + +fn normalize_provider_value(id: &str, provider: Value) -> Result { + let mut provider = provider; + let object = provider.as_object_mut().ok_or_else(|| { + AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + ) + })?; + + let has_identifier = ["name", "id", "provider"].iter().any(|key| { + object + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .is_some_and(|value| !value.is_empty()) + }); + if !has_identifier { + object.insert("name".to_string(), Value::String(id.to_string())); + } + + Ok(provider) +} + +pub fn get_current_provider_id() -> Result, AppError> { + let config = read_hermes_config()?; + let Some(model) = config.get("model").and_then(Value::as_object) else { + return Ok(None); + }; + + let provider_ref = model + .get("provider") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or_default(); + + if let Some(custom_id) = provider_ref.strip_prefix("custom:") { + let custom_id = custom_id.trim(); + if !custom_id.is_empty() { + return Ok(Some(custom_id.to_string())); + } + } + + if provider_ref == "custom" { + let default_model = model + .get("default") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if let Some(default_model) = default_model { + for (id, provider) in get_providers()? { + if provider_matches_model(&provider, default_model) { + return Ok(Some(id)); + } + } + } + } + + Ok(None) +} + +pub fn get_providers() -> Result, AppError> { + let config = read_hermes_config()?; + let custom_providers = config + .get("custom_providers") + .cloned() + .unwrap_or_else(|| Value::Array(Vec::new())); + + let mut providers = IndexMap::new(); + match custom_providers { + Value::Array(entries) => { + for entry in entries { + if let Some(id) = provider_id_from_value(&entry) { + providers.insert(id, entry); + } + } + } + Value::Object(entries) => { + for (id, entry) in entries { + providers.insert(id, entry); + } + } + _ => {} + } + + Ok(providers) +} + +pub fn set_current_provider(id: &str, provider: &Value) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + let model = root + .entry("model".to_string()) + .or_insert_with(|| Value::Object(Map::new())); + let model = ensure_object(model); + + model.insert( + "provider".to_string(), + Value::String(format!("custom:{id}")), + ); + if let Some(model_id) = primary_model_id_from_value(provider) { + model.insert("default".to_string(), Value::String(model_id)); + } + + write_hermes_config(&config) +} + +pub fn set_provider(id: &str, provider: Value) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + let normalized = normalize_provider_value(id, provider)?; + + match root + .entry("custom_providers".to_string()) + .or_insert_with(|| Value::Array(Vec::new())) + { + Value::Array(entries) => { + let mut replaced = false; + for entry in entries.iter_mut() { + if provider_id_from_value(entry).as_deref() == Some(id) { + *entry = normalized.clone(); + replaced = true; + break; + } + } + if !replaced { + entries.push(normalized); + } + } + Value::Object(entries) => { + entries.insert(id.to_string(), normalized); + } + slot => { + *slot = Value::Array(vec![normalized]); + } + } + + write_hermes_config(&config) +} + +pub fn remove_provider(id: &str) -> Result<(), AppError> { + let mut config = read_hermes_config()?; + let root = ensure_object(&mut config); + + if let Some(custom_providers) = root.get_mut("custom_providers") { + match custom_providers { + Value::Array(entries) => { + entries.retain(|entry| provider_id_from_value(entry).as_deref() != Some(id)); + } + Value::Object(entries) => { + entries.remove(id); + } + _ => { + *custom_providers = Value::Array(Vec::new()); + } + } + } + + write_hermes_config(&config) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0a7c4f64..3b3f6e6c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,6 +10,7 @@ mod deeplink; mod error; mod gemini_config; mod gemini_mcp; +pub mod hermes_config; mod import_export; mod init_status; mod mcp; diff --git a/src-tauri/src/prompt_files.rs b/src-tauri/src/prompt_files.rs index 018dee81..20552b79 100644 --- a/src-tauri/src/prompt_files.rs +++ b/src-tauri/src/prompt_files.rs @@ -6,7 +6,7 @@ use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::gemini_config::get_gemini_dir; use crate::opencode_config::get_opencode_dir; -use crate::settings::get_openclaw_override_dir; +use crate::settings::{get_hermes_override_dir, get_openclaw_override_dir}; /// 返回指定应用所使用的提示词文件路径。 pub fn prompt_file_path(app: &AppType) -> Result { @@ -15,6 +15,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => get_base_dir_with_fallback(get_codex_auth_path(), ".codex")?, AppType::Gemini => get_gemini_dir(), AppType::OpenCode => get_opencode_dir(), + AppType::Hermes => get_hermes_override_dir().unwrap_or_else(default_hermes_dir), AppType::OpenClaw => get_openclaw_override_dir().unwrap_or_else(default_openclaw_dir), }; @@ -23,6 +24,7 @@ pub fn prompt_file_path(app: &AppType) -> Result { AppType::Codex => "AGENTS.md", AppType::Gemini => "GEMINI.md", AppType::OpenCode => "AGENTS.md", + AppType::Hermes => "AGENTS.md", AppType::OpenClaw => "AGENTS.md", }; @@ -35,6 +37,12 @@ fn default_openclaw_dir() -> PathBuf { .unwrap_or_else(|| PathBuf::from(".openclaw")) } +fn default_hermes_dir() -> PathBuf { + dirs::home_dir() + .map(|home| home.join(".hermes")) + .unwrap_or_else(|| PathBuf::from(".hermes")) +} + fn get_base_dir_with_fallback( primary_path: PathBuf, fallback_dir: &str, diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs index 4a36471b..e414669e 100644 --- a/src-tauri/src/proxy/providers/mod.rs +++ b/src-tauri/src/proxy/providers/mod.rs @@ -114,7 +114,7 @@ impl ProviderType { } ProviderType::Gemini } - AppType::OpenCode | AppType::OpenClaw => ProviderType::Codex, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => ProviderType::Codex, } } @@ -164,6 +164,7 @@ pub fn get_adapter(app_type: &AppType) -> Box { AppType::Codex => Box::new(CodexAdapter::new()), AppType::Gemini => Box::new(GeminiAdapter::new()), AppType::OpenCode => Box::new(CodexAdapter::new()), + AppType::Hermes => Box::new(CodexAdapter::new()), AppType::OpenClaw => Box::new(CodexAdapter::new()), } } diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs index b75c14bb..88dc160d 100644 --- a/src-tauri/src/services/config.rs +++ b/src-tauri/src/services/config.rs @@ -252,6 +252,7 @@ impl ConfigService { Self::sync_current_provider_for_app(config, &AppType::Codex)?; Self::sync_current_provider_for_app(config, &AppType::Gemini)?; Self::sync_current_provider_for_app(config, &AppType::OpenCode)?; + Self::sync_current_provider_for_app(config, &AppType::Hermes)?; Self::sync_current_provider_for_app(config, &AppType::OpenClaw)?; Ok(()) } @@ -288,6 +289,7 @@ impl ConfigService { AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?, AppType::OpenCode => {} + AppType::Hermes => {} AppType::OpenClaw => {} } diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs index ae4d465f..6af30cca 100644 --- a/src-tauri/src/services/mcp.rs +++ b/src-tauri/src/services/mcp.rs @@ -163,6 +163,7 @@ impl McpService { AppType::OpenCode => { mcp::sync_single_server_to_opencode(cfg, &server.id, &server.server)?; } + AppType::Hermes => {} AppType::OpenClaw => {} } Ok(()) @@ -187,6 +188,7 @@ impl McpService { AppType::Codex => mcp::remove_server_from_codex(id)?, AppType::Gemini => mcp::remove_server_from_gemini(id)?, AppType::OpenCode => mcp::remove_server_from_opencode(id)?, + AppType::Hermes => {} AppType::OpenClaw => {} } Ok(()) @@ -197,7 +199,7 @@ impl McpService { let servers = Self::get_all_servers(state)?; for app in AppType::all() { - if matches!(app, AppType::OpenClaw) { + if matches!(app, AppType::Hermes | AppType::OpenClaw) { continue; } diff --git a/src-tauri/src/services/provider/common_config.rs b/src-tauri/src/services/provider/common_config.rs index 32ca914d..f1010160 100644 --- a/src-tauri/src/services/provider/common_config.rs +++ b/src-tauri/src/services/provider/common_config.rs @@ -286,7 +286,7 @@ fn parse_json_object_snippet( format!("Gemini 通用配置片段不是有效的 JSON:{e}"), format!("Gemini common config snippet is not valid JSON: {e}"), ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.invalid_json", format!("OpenCode 通用配置片段不是有效的 JSON:{e}"), format!("OpenCode common config snippet is not valid JSON: {e}"), @@ -306,7 +306,7 @@ fn parse_json_object_snippet( "Gemini 通用配置片段必须是 JSON 对象", "Gemini common config snippet must be a JSON object", ), - AppType::OpenCode | AppType::OpenClaw => AppError::localized( + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => AppError::localized( "common_config.opencode.not_object", "OpenCode 通用配置片段必须是 JSON 对象", "OpenCode common config snippet must be a JSON object", @@ -378,7 +378,11 @@ pub(super) fn validate_common_config_snippet( } match app_type { - AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => { + AppType::Claude + | AppType::Gemini + | AppType::OpenCode + | AppType::Hermes + | AppType::OpenClaw => { parse_json_object_snippet(app_type, snippet, false)?; } AppType::Codex => { @@ -452,7 +456,7 @@ pub(super) fn settings_contain_common_config( } _ => false, }, - AppType::OpenCode | AppType::OpenClaw => false, + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false, } } @@ -524,7 +528,7 @@ pub(super) fn apply_common_config_to_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } @@ -577,7 +581,7 @@ pub(super) fn remove_common_config_from_settings( } Ok(result) } - AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(settings.clone()), } } diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index ba51084a..0041b6de 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -26,6 +26,9 @@ pub(super) enum LiveSnapshot { OpenCode { config: Option, }, + Hermes { + config_source: Option, + }, OpenClaw { config_source: Option, }, @@ -88,6 +91,14 @@ impl LiveSnapshot { delete_file(&path)?; } } + LiveSnapshot::Hermes { config_source } => { + let path = crate::hermes_config::get_hermes_config_path(); + if let Some(source) = config_source { + crate::hermes_config::write_hermes_config_source(source)?; + } else if path.exists() { + delete_file(&path)?; + } + } LiveSnapshot::OpenClaw { config_source } => { let path = crate::openclaw_config::get_openclaw_config_path(); if let Some(source) = config_source { @@ -155,6 +166,10 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result { + let config_source = crate::hermes_config::read_hermes_config_source()?; + Ok(LiveSnapshot::Hermes { config_source }) + } AppType::OpenClaw => { let config_source = crate::openclaw_config::read_openclaw_config_source()?; Ok(LiveSnapshot::OpenClaw { config_source }) @@ -162,6 +177,54 @@ pub(super) fn capture_live_snapshot(app_type: &AppType) -> Result Result { + let providers = crate::hermes_config::get_providers()?; + if providers.is_empty() { + return Ok(0); + } + + let mut imported = 0usize; + let existing_ids = state.db.get_provider_ids("hermes")?; + + for (id, settings_config) in providers { + if id.trim().is_empty() { + log::warn!("Skipping Hermes provider with empty id"); + continue; + } + if existing_ids.contains(&id) { + log::debug!("Hermes provider '{id}' already exists in database, skipping"); + continue; + } + if !settings_config.is_object() { + log::warn!("Skipping Hermes provider '{id}': config is not an object"); + continue; + } + + let display_name = settings_config + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&id) + .to_string(); + let mut provider = Provider::with_id(id.clone(), display_name, settings_config, None); + provider.meta = Some(ProviderMeta { + live_config_managed: Some(true), + ..Default::default() + }); + + if let Err(err) = state.db.save_provider("hermes", &provider) { + log::warn!("Failed to import Hermes provider '{id}': {err}"); + continue; + } + + imported += 1; + log::info!("Imported Hermes provider '{id}' from live config"); + } + + Ok(imported) +} + pub fn sync_openclaw_providers_from_live(state: &AppState) -> Result { if !crate::openclaw_config::get_openclaw_config_path().exists() { return Ok(0); diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 257e5be5..bbf573d4 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -78,6 +78,7 @@ struct PostCommitAction { refresh_snapshot: bool, common_config_snippet: Option, takeover_active: bool, + activate_provider: bool, } impl ProviderService { @@ -200,6 +201,8 @@ impl ProviderService { let read_presence = || match app_type { AppType::OpenCode => crate::opencode_config::get_providers() .map(|providers| providers.contains_key(provider_id)), + AppType::Hermes => crate::hermes_config::get_providers() + .map(|providers| providers.contains_key(provider_id)), AppType::OpenClaw => Self::valid_openclaw_live_provider_ids() .map(|ids| ids.is_some_and(|ids| ids.contains(provider_id))), _ => Ok(false), @@ -393,6 +396,12 @@ impl ProviderService { action.common_config_snippet.as_deref(), apply_common_config, )?; + if action.activate_provider && matches!(action.app_type, AppType::Hermes) { + crate::hermes_config::set_current_provider( + &action.provider.id, + &action.provider.settings_config, + )?; + } } if action.sync_mcp { // 使用 v3.7.0 统一的 MCP 同步机制,支持所有应用 @@ -633,6 +642,26 @@ impl ProviderService { } state.save()?; } + AppType::Hermes => { + let providers = crate::hermes_config::get_providers()?; + let live_after = providers.get(provider_id).cloned().ok_or_else(|| { + AppError::localized( + "hermes.live.missing_provider", + format!("Hermes live 配置中缺少供应商: {provider_id}"), + format!("Hermes live config missing provider: {provider_id}"), + ) + })?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + if let Some(manager) = guard.get_manager_mut(app_type) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + } + state.save()?; + } AppType::OpenClaw => { let providers = crate::openclaw_config::get_providers()?; let live_after = providers.get(provider_id).cloned().ok_or_else(|| { @@ -708,7 +737,7 @@ impl ProviderService { strict_current_provider_id, old_snippet, ), - AppType::OpenCode | AppType::OpenClaw => Ok(()), + AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => Ok(()), }; match result { @@ -775,6 +804,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet: config.common_config_snippets.get(app_type).cloned(), takeover_active, + activate_provider: false, })) } @@ -1082,6 +1112,10 @@ impl ProviderService { /// 获取当前供应商 ID pub fn current(state: &AppState, app_type: AppType) -> Result { + if matches!(app_type, AppType::Hermes) { + return crate::hermes_config::get_current_provider_id() + .map(|opt| opt.unwrap_or_default()); + } if app_type.is_additive_mode() { return Ok(String::new()); } @@ -1158,6 +1192,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1271,6 +1306,7 @@ impl ProviderService { refresh_snapshot: false, common_config_snippet, takeover_active: false, + activate_provider: false, }) } else { None @@ -1353,6 +1389,7 @@ impl ProviderService { }) } AppType::OpenCode => unreachable!("additive mode apps are handled earlier"), + AppType::Hermes => unreachable!("additive mode apps are handled earlier"), AppType::OpenClaw => unreachable!("additive mode apps are handled earlier"), }; @@ -1451,6 +1488,17 @@ impl ProviderService { } crate::opencode_config::read_opencode_config() } + AppType::Hermes => { + let config_path = crate::hermes_config::get_hermes_config_path(); + if !config_path.exists() { + return Err(AppError::localized( + "hermes.config.missing", + "Hermes 配置文件不存在", + "Hermes configuration file not found", + )); + } + crate::hermes_config::read_hermes_config() + } AppType::OpenClaw => { let config_path = crate::openclaw_config::get_openclaw_config_path(); if !config_path.exists() { @@ -1523,6 +1571,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -1741,6 +1794,7 @@ impl ProviderService { .get(&app_type_clone) .cloned(), takeover_active: false, + activate_provider: matches!(&app_type_clone, AppType::Hermes), }; return Ok(((), Some(action))); @@ -1764,6 +1818,7 @@ impl ProviderService { effective_current_provider.as_deref(), )?, AppType::OpenCode => unreachable!("additive mode handled above"), + AppType::Hermes => unreachable!("additive mode handled above"), AppType::OpenClaw => unreachable!("additive mode handled above"), }; @@ -1775,6 +1830,7 @@ impl ProviderService { refresh_snapshot: true, common_config_snippet: config.common_config_snippets.get(&app_type_clone).cloned(), takeover_active: false, + activate_provider: false, }; Ok(((), Some(action))) @@ -1836,6 +1892,16 @@ impl ProviderService { Err(_) => crate::opencode_config::set_provider(&provider.id, config_to_write), } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + crate::hermes_config::set_provider(&provider.id, provider.settings_config.clone()) + } AppType::OpenClaw => { let settings_config = provider.settings_config.clone(); let looks_like_provider = settings_config.get("baseUrl").is_some() @@ -2062,6 +2128,9 @@ impl ProviderService { AppType::OpenCode => Err(AppError::Config( "OpenCode does not support proxy takeover backups".into(), )), + AppType::Hermes => Err(AppError::Config( + "Hermes does not support proxy takeover backups".into(), + )), AppType::OpenClaw => Err(AppError::Config( "OpenClaw does not support proxy takeover backups".into(), )), @@ -2146,6 +2215,15 @@ impl ProviderService { )); } } + AppType::Hermes => { + if !provider.settings_config.is_object() { + return Err(AppError::localized( + "provider.hermes.settings.not_object", + "Hermes 配置必须是 JSON 对象", + "Hermes configuration must be a JSON object", + )); + } + } AppType::OpenClaw => { let config = Self::parse_openclaw_provider_settings(&provider.settings_config)?; Self::validate_openclaw_provider_models(&provider.id, &config)?; @@ -2278,6 +2356,11 @@ impl ProviderService { crate::opencode_config::remove_provider(provider_id)?; } } + AppType::Hermes => { + if crate::hermes_config::get_hermes_dir().exists() { + crate::hermes_config::remove_provider(provider_id)?; + } + } AppType::OpenClaw => { if crate::openclaw_config::get_openclaw_dir().exists() { crate::openclaw_config::remove_provider(provider_id)?; @@ -2318,6 +2401,9 @@ impl ProviderService { AppType::OpenCode => { let _ = provider_snapshot; } + AppType::Hermes => { + let _ = provider_snapshot; + } AppType::OpenClaw => { let _ = provider_snapshot; } @@ -2354,6 +2440,10 @@ impl ProviderService { live::import_openclaw_providers_from_live(state) } + pub fn import_hermes_providers_from_live(state: &AppState) -> Result { + live::import_hermes_providers_from_live(state) + } + pub fn import_opencode_providers_from_live(state: &AppState) -> Result { live::import_opencode_providers_from_live(state) } diff --git a/src-tauri/src/services/provider/usage.rs b/src-tauri/src/services/provider/usage.rs index b6cfeec5..0db8dac9 100644 --- a/src-tauri/src/services/provider/usage.rs +++ b/src-tauri/src/services/provider/usage.rs @@ -267,6 +267,19 @@ impl ProviderService { ) }) .map(|s| s.to_string()), + AppType::Hermes => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|v| v.as_str()) + .ok_or_else(|| { + AppError::localized( + "provider.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }) + .map(|s| s.to_string()), AppType::OpenClaw => provider .settings_config .get("apiKey") @@ -356,6 +369,14 @@ impl ProviderService { .and_then(|v| v.as_str()) .unwrap_or_default() .to_string()), + AppType::Hermes => Ok(provider + .settings_config + .get("baseUrl") + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f90df1a1..07072b02 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -382,7 +382,7 @@ pub struct SkillService { impl SkillService { fn app_supports_skills(app: &AppType) -> bool { - !matches!(app, AppType::OpenClaw) + !matches!(app, AppType::Hermes | AppType::OpenClaw) } fn supported_skill_apps() -> impl Iterator { @@ -444,6 +444,11 @@ impl SkillService { return Ok(custom.join("skills")); } } + AppType::Hermes => { + if let Some(custom) = crate::settings::get_hermes_override_dir() { + return Ok(custom.join("skills")); + } + } AppType::OpenClaw => { if let Some(custom) = crate::settings::get_openclaw_override_dir() { return Ok(custom.join("skills")); @@ -464,6 +469,7 @@ impl SkillService { AppType::Codex => home.join(".codex").join("skills"), AppType::Gemini => home.join(".gemini").join("skills"), AppType::OpenCode => home.join(".config").join("opencode").join("skills"), + AppType::Hermes => home.join(".hermes").join("skills"), AppType::OpenClaw => home.join(".openclaw").join("skills"), }) } diff --git a/src-tauri/src/services/stream_check/provider_extract.rs b/src-tauri/src/services/stream_check/provider_extract.rs index 8b56de99..717b4355 100644 --- a/src-tauri/src/services/stream_check/provider_extract.rs +++ b/src-tauri/src/services/stream_check/provider_extract.rs @@ -30,6 +30,28 @@ impl StreamCheckService { .and_then(|value| value.as_object()) .and_then(|models| models.keys().next().cloned()) .unwrap_or_else(|| config.codex_model.clone()), + AppType::Hermes => provider + .settings_config + .get("model") + .and_then(|value| value.as_str()) + .map(str::to_string) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_object()) + .and_then(|models| models.keys().next().cloned()) + }) + .or_else(|| { + provider + .settings_config + .get("models") + .and_then(|value| value.as_array()) + .and_then(|models| models.first()) + .and_then(|model| model.get("id").and_then(|value| value.as_str())) + .map(str::to_string) + }) + .unwrap_or_else(|| config.codex_model.clone()), AppType::OpenClaw => provider .settings_config .get("models") @@ -168,6 +190,16 @@ impl StreamCheckService { .unwrap_or_default() .trim_end_matches('/') .to_string()), + AppType::Hermes => Ok(provider + .settings_config + .get("base_url") + .or_else(|| provider.settings_config.get("baseUrl")) + .or_else(|| provider.settings_config.get("baseURL")) + .or_else(|| provider.settings_config.get("endpoint")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .trim_end_matches('/') + .to_string()), AppType::OpenClaw => Ok(provider .settings_config .get("baseUrl") @@ -218,6 +250,19 @@ impl StreamCheckService { "API key is missing", ) }), + AppType::Hermes => provider + .settings_config + .get("apiKey") + .or_else(|| provider.settings_config.get("api_key")) + .and_then(|value| value.as_str()) + .map(|key| AuthInfo::new(key.to_string(), AuthStrategy::Bearer)) + .ok_or_else(|| { + AppError::localized( + "provider.hermes.api_key.missing", + "缺少 API Key", + "API key is missing", + ) + }), AppType::OpenClaw => provider .settings_config .get("apiKey") diff --git a/src-tauri/src/services/stream_check/service.rs b/src-tauri/src/services/stream_check/service.rs index 7f9314a1..4bc2d8f4 100644 --- a/src-tauri/src/services/stream_check/service.rs +++ b/src-tauri/src/services/stream_check/service.rs @@ -103,8 +103,8 @@ impl StreamCheckService { provider: &Provider, config: &StreamCheckConfig, ) -> Result { - if matches!(app_type, AppType::OpenClaw) { - return Err(AppError::Message("OpenClaw 暂不支持流式检查".to_string())); + if matches!(app_type, AppType::Hermes | AppType::OpenClaw) { + return Err(AppError::Message(format!("{} 暂不支持流式检查", app_type))); } let start = Instant::now(); @@ -161,6 +161,7 @@ impl StreamCheckService { ) .await } + AppType::Hermes => unreachable!("Hermes should return unsupported earlier"), AppType::OpenClaw => unreachable!("OpenClaw should return unsupported earlier"), }; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 4f8639a9..da67af04 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -18,6 +18,8 @@ pub struct VisibleApps { pub gemini: bool, #[serde(default = "default_visible_app_opencode")] pub opencode: bool, + #[serde(default = "default_visible_app_hermes")] + pub hermes: bool, #[serde(default = "default_visible_app_openclaw")] pub openclaw: bool, } @@ -38,6 +40,10 @@ fn default_visible_app_opencode() -> bool { true } +fn default_visible_app_hermes() -> bool { + true +} + fn default_visible_app_openclaw() -> bool { true } @@ -48,6 +54,7 @@ pub fn default_visible_apps() -> VisibleApps { codex: true, gemini: false, opencode: true, + hermes: true, openclaw: true, } } @@ -72,6 +79,7 @@ impl VisibleApps { AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, + AppType::Hermes => self.hermes, AppType::OpenClaw => self.openclaw, } } @@ -82,6 +90,7 @@ impl VisibleApps { AppType::Codex => self.codex = enabled, AppType::Gemini => self.gemini = enabled, AppType::OpenCode => self.opencode = enabled, + AppType::Hermes => self.hermes = enabled, AppType::OpenClaw => self.openclaw = enabled, } } @@ -103,12 +112,13 @@ impl VisibleApps { } } -fn app_order() -> [AppType; 5] { +fn app_order() -> [AppType; 6] { [ AppType::Claude, AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] } @@ -307,6 +317,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub hermes_config_dir: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, @@ -317,6 +329,8 @@ pub struct AppSettings { #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_provider_hermes: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, #[serde(default = "default_visible_apps")] pub visible_apps: VisibleApps, @@ -361,11 +375,13 @@ impl Default for AppSettings { codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, + hermes_config_dir: None, openclaw_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, + current_provider_hermes: None, current_provider_openclaw: None, visible_apps: default_visible_apps(), language: None, @@ -416,6 +432,13 @@ impl AppSettings { .filter(|s| !s.is_empty()) .map(|s| s.to_string()); + self.hermes_config_dir = self + .hermes_config_dir + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + self.openclaw_config_dir = self .openclaw_config_dir .as_ref() @@ -586,6 +609,14 @@ pub fn get_opencode_override_dir() -> Option { .map(|p| resolve_override_path(p)) } +pub fn get_hermes_override_dir() -> Option { + let settings = settings_store().read().ok()?; + settings + .hermes_config_dir + .as_ref() + .map(|p| resolve_override_path(p)) +} + pub fn get_openclaw_override_dir() -> Option { let settings = settings_store().read().ok()?; settings @@ -601,6 +632,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option { AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), + AppType::Hermes => settings.current_provider_hermes.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } @@ -613,6 +645,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppType::Codex => settings.current_provider_codex = id.map(|value| value.to_string()), AppType::Gemini => settings.current_provider_gemini = id.map(|value| value.to_string()), AppType::OpenCode => settings.current_provider_opencode = id.map(|value| value.to_string()), + AppType::Hermes => settings.current_provider_hermes = id.map(|value| value.to_string()), AppType::OpenClaw => settings.current_provider_openclaw = id.map(|value| value.to_string()), } diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 3ed4a61a..4929ddc2 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -145,6 +145,14 @@ impl AppState { Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"), } + match crate::services::provider::ProviderService::import_hermes_providers_from_live(self) { + 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(error) => log::warn!("✗ Failed to import Hermes providers: {error}"), + } + match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self) { Ok(count) if count > 0 => { @@ -208,6 +216,7 @@ fn export_db_to_multi_app_config(db: &Database) -> Result Result config.prompts.codex.prompts = prompts.into_iter().collect(), AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(), AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(), + AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(), AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(), } @@ -259,6 +269,7 @@ fn persist_multi_app_config_to_db_preserving_current_providers( AppType::Codex, AppType::Gemini, AppType::OpenCode, + AppType::Hermes, AppType::OpenClaw, ] { let app_key = app.as_str(); diff --git a/src-tauri/src/sync_policy.rs b/src-tauri/src/sync_policy.rs index 7eb92699..a744d11f 100644 --- a/src-tauri/src/sync_policy.rs +++ b/src-tauri/src/sync_policy.rs @@ -20,6 +20,8 @@ pub(crate) fn should_sync_live(app_type: &AppType) -> bool { AppType::Gemini => crate::gemini_config::get_gemini_dir().exists(), // OpenCode is considered initialized if ~/.config/opencode (or override dir) exists. AppType::OpenCode => crate::opencode_config::get_opencode_dir().exists(), + // Hermes is considered initialized if ~/.hermes (or override dir) exists. + AppType::Hermes => crate::hermes_config::get_hermes_dir().exists(), // OpenClaw is considered initialized if ~/.openclaw (or override dir) exists. AppType::OpenClaw => get_openclaw_dir().exists(), } diff --git a/src-tauri/tests/app_config_load.rs b/src-tauri/tests/app_config_load.rs index 7a33410d..1099b222 100644 --- a/src-tauri/tests/app_config_load.rs +++ b/src-tauri/tests/app_config_load.rs @@ -146,6 +146,19 @@ fn default_config_contains_openclaw_prompt_root_and_manager() { ); } +#[test] +fn default_config_contains_hermes_prompt_root_and_manager() { + let config = MultiAppConfig::default(); + + assert!(config + .get_manager(&cc_switch_lib::AppType::Hermes) + .is_some()); + assert!( + config.prompts.hermes.prompts.is_empty(), + "default Hermes prompt store should exist" + ); +} + #[test] fn update_settings_persists_openclaw_override_dir() { let _guard = lock_test_mutex(); @@ -168,6 +181,28 @@ fn update_settings_persists_openclaw_override_dir() { ); } +#[test] +fn update_settings_persists_hermes_override_dir() { + let _guard = lock_test_mutex(); + reset_test_fs(); + let home = ensure_test_home(); + let _config_dir = ConfigDirEnvGuard::set(None); + + let mut settings = AppSettings::default(); + settings.hermes_config_dir = Some("~/custom-hermes".to_string()); + update_settings(settings).expect("save settings with hermes override"); + + let path = home.join(".cc-switch").join("settings.json"); + let raw = fs::read_to_string(&path).expect("read settings.json"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("parse settings.json"); + assert_eq!( + value + .get("hermesConfigDir") + .and_then(|entry| entry.as_str()), + Some("~/custom-hermes") + ); +} + #[test] fn update_settings_uses_cc_switch_config_dir_override_for_settings_path() { let _guard = lock_test_mutex(); diff --git a/src-tauri/tests/app_type_parse.rs b/src-tauri/tests/app_type_parse.rs index d86943ac..0571116f 100644 --- a/src-tauri/tests/app_type_parse.rs +++ b/src-tauri/tests/app_type_parse.rs @@ -6,6 +6,7 @@ use cc_switch_lib::AppType; fn parse_known_apps_case_insensitive_and_trim() { assert!(matches!(AppType::from_str("claude"), Ok(AppType::Claude))); assert!(matches!(AppType::from_str("codex"), Ok(AppType::Codex))); + assert!(matches!(AppType::from_str("hermes"), Ok(AppType::Hermes))); assert!(matches!( AppType::from_str("openclaw"), Ok(AppType::OpenClaw) @@ -15,6 +16,10 @@ fn parse_known_apps_case_insensitive_and_trim() { Ok(AppType::Claude) )); assert!(matches!(AppType::from_str("\tcoDeX\t"), Ok(AppType::Codex))); + assert!(matches!( + AppType::from_str(" HeRmEs\t"), + Ok(AppType::Hermes) + )); assert!(matches!( AppType::from_str("\nOpenClaw\t"), Ok(AppType::OpenClaw) @@ -27,6 +32,12 @@ fn openclaw_is_listed_and_uses_additive_mode() { assert!(AppType::OpenClaw.is_additive_mode()); } +#[test] +fn hermes_is_listed_and_uses_additive_mode() { + assert!(AppType::all().any(|app| app == AppType::Hermes)); + assert!(AppType::Hermes.is_additive_mode()); +} + #[test] fn parse_unknown_app_returns_localized_error_message() { let err = AppType::from_str("unknown").unwrap_err(); diff --git a/src-tauri/tests/settings_current_provider.rs b/src-tauri/tests/settings_current_provider.rs index 8e0df568..5d5db97a 100644 --- a/src-tauri/tests/settings_current_provider.rs +++ b/src-tauri/tests/settings_current_provider.rs @@ -10,6 +10,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -20,6 +21,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -316,3 +318,43 @@ fn settings_current_provider_openclaw_falls_back_to_db_when_cleanup_fails() { Some("db-openclaw") ); } + +#[test] +#[serial] +fn settings_current_provider_hermes_matches_upstream_placeholder_behavior() { + let _home = HomeGuard::new(); + + set_current_provider(&AppType::Hermes, Some("local-hermes")) + .expect("store local hermes provider placeholder"); + assert_eq!( + get_current_provider(&AppType::Hermes).as_deref(), + Some("local-hermes") + ); + + let mut db = Database::default(); + db.insert_provider("hermes", "local-hermes"); + db.set_db_current("hermes", "db-hermes"); + + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("resolve effective hermes provider") + .as_deref(), + Some("local-hermes"), + "existing local placeholder should win while it still exists in the database" + ); + + set_current_provider(&AppType::Hermes, Some("missing-hermes")) + .expect("overwrite local hermes placeholder"); + assert_eq!( + get_effective_current_provider(&db, &AppType::Hermes) + .expect("fallback to database current provider") + .as_deref(), + Some("db-hermes"), + "missing local placeholder should be cleared and fall back to database current" + ); + assert_eq!( + get_current_provider(&AppType::Hermes), + None, + "invalid local placeholder should be removed after fallback" + ); +} diff --git a/src-tauri/tests/settings_visible_apps.rs b/src-tauri/tests/settings_visible_apps.rs index e7784e84..f5f0f80e 100644 --- a/src-tauri/tests/settings_visible_apps.rs +++ b/src-tauri/tests/settings_visible_apps.rs @@ -13,6 +13,7 @@ mod app_config { Gemini, OpenCode, OpenClaw, + Hermes, } impl AppType { @@ -23,6 +24,7 @@ mod app_config { AppType::Gemini => "gemini", AppType::OpenCode => "opencode", AppType::OpenClaw => "openclaw", + AppType::Hermes => "hermes", } } } @@ -292,6 +294,7 @@ fn default_visible_apps_hide_gemini() { AppType::Codex, AppType::OpenCode, AppType::OpenClaw, + AppType::Hermes, ] ); assert!(!visible.is_enabled_for(&AppType::Gemini)); @@ -308,6 +311,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { gemini: true, opencode: false, openclaw: true, + hermes: true, }) .expect("persist visible apps"); @@ -324,6 +328,7 @@ fn set_visible_apps_persists_visible_apps_as_camel_case_json() { "gemini": true, "opencode": false, "openclaw": true, + "hermes": true, }) ); } @@ -341,6 +346,7 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { "gemini": true, "opencode": true, "openclaw": false, + "hermes": true, } }), ); @@ -356,11 +362,17 @@ fn load_reads_valid_non_default_visible_apps_from_settings_json() { gemini: true, opencode: true, openclaw: false, + hermes: true, } ); assert_eq!( visible.ordered_enabled(), - vec![AppType::Codex, AppType::Gemini, AppType::OpenCode] + vec![ + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes + ] ); } @@ -387,6 +399,7 @@ fn load_partial_visible_apps_object_uses_defaults_for_missing_keys() { gemini: false, opencode: true, openclaw: true, + hermes: true, } ); } @@ -423,6 +436,7 @@ fn set_visible_apps_rejects_zero_selection() { gemini: false, opencode: false, openclaw: false, + hermes: false, }) .expect_err("zero visible apps should be rejected"); @@ -444,6 +458,7 @@ fn update_settings_rejects_all_false_visible_apps() { gemini: false, opencode: false, openclaw: false, + hermes: false, }; let err = @@ -491,7 +506,8 @@ fn load_normalizes_all_false_visible_apps_to_defaults() { "codex": false, "gemini": false, "opencode": false, - "openclaw": false + "openclaw": false, + "hermes": false } }), ); @@ -535,6 +551,7 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { gemini: false, opencode: true, openclaw: true, + hermes: true, }; assert_eq!( @@ -543,10 +560,18 @@ fn next_visible_app_wraps_and_skips_hidden_entries() { ); assert_eq!( next_visible_app(&visible, &AppType::OpenClaw, 1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, 1), Some(AppType::Claude) ); assert_eq!( next_visible_app(&visible, &AppType::Claude, -1), + Some(AppType::Hermes) + ); + assert_eq!( + next_visible_app(&visible, &AppType::Hermes, -1), Some(AppType::OpenClaw) ); assert_eq!(