diff --git a/src-tauri/src/app_config.rs b/src-tauri/src/app_config.rs
index 4a9fc75390..e63cf37d21 100644
--- a/src-tauri/src/app_config.rs
+++ b/src-tauri/src/app_config.rs
@@ -15,6 +15,8 @@ pub struct McpApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
+ #[serde(default)]
+ pub hermes: bool,
}
impl McpApps {
@@ -26,6 +28,7 @@ impl McpApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support MCP
+ AppType::Hermes => self.hermes,
}
}
@@ -37,6 +40,7 @@ impl McpApps {
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support MCP, ignore
+ AppType::Hermes => self.hermes = enabled,
}
}
@@ -55,12 +59,15 @@ impl McpApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
+ if self.hermes {
+ apps.push(AppType::Hermes);
+ }
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
- !self.claude && !self.codex && !self.gemini && !self.opencode
+ !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
}
}
@@ -75,6 +82,8 @@ pub struct SkillApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
+ #[serde(default)]
+ pub hermes: bool,
}
impl SkillApps {
@@ -86,6 +95,7 @@ impl SkillApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
+ AppType::Hermes => self.hermes,
}
}
@@ -97,6 +107,7 @@ impl SkillApps {
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
+ AppType::Hermes => self.hermes = enabled,
}
}
@@ -115,12 +126,15 @@ impl SkillApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
+ if self.hermes {
+ apps.push(AppType::Hermes);
+ }
apps
}
/// 检查是否所有应用都未启用
pub fn is_empty(&self) -> bool {
- !self.claude && !self.codex && !self.gemini && !self.opencode
+ !self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
}
/// 仅启用指定应用(其他应用设为禁用)
@@ -251,6 +265,9 @@ pub struct McpRoot {
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json)
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
+ /// Hermes MCP 配置(v4.2.0+,实际使用 hermes.json)
+ #[serde(default, skip_serializing_if = "McpConfig::is_empty")]
+ pub hermes: McpConfig,
}
impl Default for McpRoot {
@@ -264,6 +281,7 @@ impl Default for McpRoot {
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
+ hermes: McpConfig::default(),
}
}
}
@@ -288,6 +306,8 @@ pub struct PromptRoot {
pub opencode: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
+ #[serde(default)]
+ pub hermes: PromptConfig,
}
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
@@ -304,6 +324,7 @@ pub enum AppType {
Gemini,
OpenCode,
OpenClaw,
+ Hermes,
}
impl AppType {
@@ -314,12 +335,13 @@ impl AppType {
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::OpenClaw => "openclaw",
+ AppType::Hermes => "hermes",
}
}
/// Check if this app uses additive mode
///
- /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini)
+ /// - Switch mode (false): Only the current provider is written to live config (Claude, Codex, Gemini, Hermes)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
@@ -333,6 +355,7 @@ impl AppType {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
+ AppType::Hermes,
]
.into_iter()
}
@@ -349,10 +372,11 @@ impl FromStr for AppType {
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"openclaw" => Ok(AppType::OpenClaw),
+ "hermes" => Ok(AppType::Hermes),
other => Err(AppError::localized(
"unsupported_app",
- format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"),
- format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."),
+ format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw, hermes。"),
+ format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw, hermes."),
)),
}
}
@@ -375,6 +399,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option,
+
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub hermes: Option,
}
impl CommonConfigSnippets {
@@ -386,6 +413,7 @@ impl CommonConfigSnippets {
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
+ AppType::Hermes => self.hermes.as_ref(),
}
}
@@ -397,6 +425,7 @@ impl CommonConfigSnippets {
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::OpenClaw => self.openclaw = snippet,
+ AppType::Hermes => self.hermes = snippet,
}
}
}
@@ -438,6 +467,7 @@ impl Default for MultiAppConfig {
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());
+ apps.insert("hermes".to_string(), ProviderManager::default());
Self {
version: 2,
@@ -598,6 +628,7 @@ impl MultiAppConfig {
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::OpenClaw => &self.mcp.openclaw,
+ AppType::Hermes => &self.mcp.hermes,
}
}
@@ -609,6 +640,7 @@ impl MultiAppConfig {
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::OpenClaw => &mut self.mcp.openclaw,
+ AppType::Hermes => &mut self.mcp.hermes,
}
}
@@ -624,6 +656,7 @@ impl MultiAppConfig {
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::OpenClaw)?;
+ Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
Ok(config)
}
@@ -645,6 +678,7 @@ impl MultiAppConfig {
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
+ || !self.prompts.hermes.prompts.is_empty()
{
return Ok(false);
}
@@ -658,6 +692,7 @@ impl MultiAppConfig {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
+ AppType::Hermes,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -729,6 +764,7 @@ impl MultiAppConfig {
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
+ AppType::Hermes => &mut config.prompts.hermes.prompts,
};
prompts.insert(id, prompt);
@@ -769,6 +805,7 @@ impl MultiAppConfig {
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::OpenClaw => continue, // OpenClaw MCP is still in development, skip
+ AppType::Hermes => continue, // Hermes MCP is still in development, skip
};
for (id, entry) in old_servers {
diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs
index 60c0f1c799..358aa288c4 100644
--- a/src-tauri/src/commands/config.rs
+++ b/src-tauri/src/commands/config.rs
@@ -101,6 +101,12 @@ pub async fn get_config_status(app: String) -> Result {
Ok(ConfigStatus { exists, path })
}
+ AppType::Hermes => {
+ let hermes_dir = crate::config::get_home_dir().join(".hermes");
+ let exists = hermes_dir.exists();
+ let path = hermes_dir.to_string_lossy().to_string();
+ Ok(ConfigStatus { exists, path })
+ }
}
}
@@ -117,6 +123,7 @@ pub async fn get_config_dir(app: String) -> Result {
AppType::Gemini => crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
+ AppType::Hermes => crate::config::get_home_dir().join(".hermes"),
};
Ok(dir.to_string_lossy().to_string())
@@ -130,6 +137,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result crate::gemini_config::get_gemini_dir(),
AppType::OpenCode => crate::opencode_config::get_opencode_dir(),
AppType::OpenClaw => crate::openclaw_config::get_openclaw_dir(),
+ AppType::Hermes => crate::config::get_home_dir().join(".hermes"),
};
if !config_dir.exists() {
diff --git a/src-tauri/src/database/dao/mcp.rs b/src-tauri/src/database/dao/mcp.rs
index d5c60163f2..07eb199a3a 100644
--- a/src-tauri/src/database/dao/mcp.rs
+++ b/src-tauri/src/database/dao/mcp.rs
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
- "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
+ "SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -31,6 +31,7 @@ impl Database {
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
+ let enabled_hermes: bool = row.get(11)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -46,6 +47,7 @@ impl Database {
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
+ hermes: enabled_hermes,
},
description,
homepage,
@@ -70,8 +72,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
- enabled_claude, enabled_codex, enabled_gemini, enabled_opencode
- ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
+ enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
server.id,
server.name,
@@ -87,6 +89,7 @@ impl Database {
server.apps.codex,
server.apps.gemini,
server.apps.opencode,
+ server.apps.hermes,
],
)
.map_err(|e| AppError::Database(e.to_string()))?;
diff --git a/src-tauri/src/database/dao/skills.rs b/src-tauri/src/database/dao/skills.rs
index befedca128..1e047aad1b 100644
--- a/src-tauri/src/database/dao/skills.rs
+++ b/src-tauri/src/database/dao/skills.rs
@@ -3,7 +3,7 @@
//! 提供 Skills 和 Skill Repos 的 CRUD 操作。
//!
//! v3.10.0+ 统一管理架构:
-//! - Skills 使用统一的 id 主键,支持四应用启用标志
+//! - Skills 使用统一的 id 主键,支持五应用启用标志
//! - 实际文件存储在 ~/.cc-switch/skills/,同步到各应用目录
use crate::app_config::{InstalledSkill, SkillApps};
@@ -22,7 +22,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
- readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
+ readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
@@ -44,10 +44,11 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
+ hermes: row.get(12)?,
},
- installed_at: row.get(12)?,
- content_hash: row.get(13)?,
- updated_at: row.get::<_, i64>(14).unwrap_or(0),
+ installed_at: row.get(13)?,
+ content_hash: row.get(14)?,
+ updated_at: row.get::<_, i64>(15).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -66,7 +67,7 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
- readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
+ readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
@@ -87,10 +88,11 @@ impl Database {
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
+ hermes: row.get(12)?,
},
- installed_at: row.get(12)?,
- content_hash: row.get(13)?,
- updated_at: row.get::<_, i64>(14).unwrap_or(0),
+ installed_at: row.get(13)?,
+ content_hash: row.get(14)?,
+ updated_at: row.get::<_, i64>(15).unwrap_or(0),
})
});
@@ -107,9 +109,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
- readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
+ readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)",
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
params![
skill.id,
skill.name,
@@ -123,6 +125,7 @@ impl Database {
skill.apps.codex,
skill.apps.gemini,
skill.apps.opencode,
+ skill.apps.hermes,
skill.installed_at,
skill.content_hash,
skill.updated_at,
@@ -154,8 +157,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
- "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4 WHERE id = ?5",
- params![apps.claude, apps.codex, apps.gemini, apps.opencode, id],
+ "UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
+ params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs
index 1c7618072b..1c93ce9820 100644
--- a/src-tauri/src/database/mod.rs
+++ b/src-tauri/src/database/mod.rs
@@ -44,7 +44,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
-pub(crate) const SCHEMA_VERSION: i32 = 9;
+pub(crate) const SCHEMA_VERSION: i32 = 10;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string(value: &T) -> Result {
diff --git a/src-tauri/src/database/schema.rs b/src-tauri/src/database/schema.rs
index 295641457a..70183bb5ae 100644
--- a/src-tauri/src/database/schema.rs
+++ b/src-tauri/src/database/schema.rs
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
- enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0
+ enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
+ enabled_hermes BOOLEAN NOT NULL DEFAULT 0
)",
[],
)
@@ -93,6 +94,7 @@ impl Database {
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
+ enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
content_hash TEXT,
updated_at INTEGER NOT NULL DEFAULT 0
@@ -423,6 +425,11 @@ impl Database {
Self::migrate_v8_to_v9(conn)?;
Self::set_user_version(conn, 9)?;
}
+ 9 => {
+ log::info!("迁移数据库从 v9 到 v10(Hermes 支持)");
+ Self::migrate_v9_to_v10(conn)?;
+ Self::set_user_version(conn, 10)?;
+ }
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -1168,6 +1175,34 @@ impl Database {
Ok(())
}
+ /// v9 → v10: 添加 Hermes 支持
+ ///
+ /// 为 mcp_servers 和 skills 表添加 enabled_hermes 列。
+ fn migrate_v9_to_v10(conn: &Connection) -> Result<(), AppError> {
+ // 为 mcp_servers 表添加 enabled_hermes 列
+ if Self::table_exists(conn, "mcp_servers")? {
+ Self::add_column_if_missing(
+ conn,
+ "mcp_servers",
+ "enabled_hermes",
+ "BOOLEAN NOT NULL DEFAULT 0",
+ )?;
+ }
+
+ // 为 skills 表添加 enabled_hermes 列
+ if Self::table_exists(conn, "skills")? {
+ Self::add_column_if_missing(
+ conn,
+ "skills",
+ "enabled_hermes",
+ "BOOLEAN NOT NULL DEFAULT 0",
+ )?;
+ }
+
+ log::info!("v9 -> v10 迁移完成:已添加 Hermes 支持");
+ Ok(())
+ }
+
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
diff --git a/src-tauri/src/deeplink/mcp.rs b/src-tauri/src/deeplink/mcp.rs
index 37df965798..822cd839d1 100644
--- a/src-tauri/src/deeplink/mcp.rs
+++ b/src-tauri/src/deeplink/mcp.rs
@@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
};
for app in apps_str.split(',') {
@@ -179,6 +180,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
+ "hermes" => apps.hermes = true,
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
diff --git a/src-tauri/src/deeplink/provider.rs b/src-tauri/src/deeplink/provider.rs
index 87e07282f4..ed6639da57 100644
--- a/src-tauri/src/deeplink/provider.rs
+++ b/src-tauri/src/deeplink/provider.rs
@@ -147,6 +147,7 @@ pub(crate) fn build_provider_from_request(
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
+ AppType::Hermes => build_hermes_settings(request),
};
// Build usage script configuration if provided
@@ -422,6 +423,46 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value
json!(config)
}
+/// Build Hermes provider settings_config from deeplink request
+///
+/// Hermes uses snake_case field names to match config.yaml format:
+/// - name: Provider display name
+/// - base_url: API endpoint
+/// - api_key: API key
+/// - model: Model name
+/// - transport: Transport type (default: "openai_chat")
+fn build_hermes_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
+ let endpoint = get_primary_endpoint(request);
+
+ // Build Hermes provider config using snake_case
+ let mut config = serde_json::Map::new();
+
+ // Provider name
+ if let Some(name) = &request.name {
+ config.insert("name".to_string(), json!(name));
+ }
+
+ // API endpoint (snake_case)
+ if !endpoint.is_empty() {
+ config.insert("base_url".to_string(), json!(endpoint));
+ }
+
+ // API key (snake_case)
+ if let Some(api_key) = &request.api_key {
+ config.insert("api_key".to_string(), json!(api_key));
+ }
+
+ // Model name (single string, not array)
+ if let Some(model) = &request.model {
+ config.insert("model".to_string(), json!(model));
+ }
+
+ // Transport type (default to openai_chat for compatibility)
+ config.insert("transport".to_string(), json!("openai_chat"));
+
+ json!(config)
+}
+
// =============================================================================
// Config Merge Logic
// =============================================================================
diff --git a/src-tauri/src/hermes_config.rs b/src-tauri/src/hermes_config.rs
new file mode 100644
index 0000000000..e85012c08c
--- /dev/null
+++ b/src-tauri/src/hermes_config.rs
@@ -0,0 +1,763 @@
+use crate::config::{get_home_dir, write_text_file};
+use crate::error::AppError;
+use crate::gemini_config::{parse_env_file, serialize_env_file};
+use crate::provider::Provider;
+use serde_json::Value as JsonValue;
+use std::collections::HashMap;
+use std::fs;
+use std::path::PathBuf;
+
+/// 获取 Hermes 配置目录路径(支持设置覆盖)
+pub fn get_hermes_dir() -> PathBuf {
+ if let Some(custom) = crate::settings::get_hermes_override_dir() {
+ return custom;
+ }
+
+ get_home_dir().join(".hermes")
+}
+
+/// 获取 Hermes config.yaml 文件路径
+pub fn get_hermes_config_path() -> PathBuf {
+ get_hermes_dir().join("config.yaml")
+}
+
+/// 获取 Hermes .env 文件路径
+pub fn get_hermes_env_path() -> PathBuf {
+ get_hermes_dir().join(".env")
+}
+
+/// 读取 Hermes config.yaml 文件
+///
+/// 如果文件不存在,返回空的 YAML mapping。
+pub fn read_hermes_config() -> Result {
+ let path = get_hermes_config_path();
+
+ if !path.exists() {
+ return Ok(serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
+ }
+
+ let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
+ let yaml = serde_yaml::from_str(&content).map_err(|e| {
+ AppError::localized(
+ "hermes.config.parse_error",
+ format!("Hermes config.yaml 解析失败: {e}"),
+ format!("Failed to parse Hermes config.yaml: {e}"),
+ )
+ })?;
+
+ Ok(yaml)
+}
+
+/// 原子写入 Hermes config.yaml 文件(temp + rename)
+pub fn write_hermes_config_atomic(yaml: &serde_yaml::Value) -> 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))?;
+ }
+
+ let content = serde_yaml::to_string(yaml).map_err(|e| {
+ AppError::localized(
+ "hermes.config.serialize_error",
+ format!("Hermes config.yaml 序列化失败: {e}"),
+ format!("Failed to serialize Hermes config.yaml: {e}"),
+ )
+ })?;
+
+ write_text_file(&path, &content)?;
+
+ Ok(())
+}
+
+/// 读取 Hermes .env 文件
+#[allow(dead_code)]
+pub fn read_hermes_env() -> Result, AppError> {
+ let path = get_hermes_env_path();
+
+ if !path.exists() {
+ return Ok(HashMap::new());
+ }
+
+ let content = fs::read_to_string(&path).map_err(|e| AppError::io(&path, e))?;
+
+ Ok(parse_env_file(&content))
+}
+
+/// 原子写入 Hermes .env 文件(temp + rename)
+pub fn write_hermes_env_atomic(env: &HashMap) -> Result<(), AppError> {
+ let path = get_hermes_env_path();
+
+ if let Some(parent) = path.parent() {
+ fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
+
+ // 设置目录权限为 700(仅所有者可读写执行)
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(parent)
+ .map_err(|e| AppError::io(parent, e))?
+ .permissions();
+ perms.set_mode(0o700);
+ fs::set_permissions(parent, perms).map_err(|e| AppError::io(parent, e))?;
+ }
+ }
+
+ let content = serialize_env_file(env);
+ write_text_file(&path, &content)?;
+
+ // 设置文件权限为 600(仅所有者可读写)
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mut perms = fs::metadata(&path)
+ .map_err(|e| AppError::io(&path, e))?
+ .permissions();
+ perms.set_mode(0o600);
+ fs::set_permissions(&path, perms).map_err(|e| AppError::io(&path, e))?;
+ }
+
+ Ok(())
+}
+
+/// 读取 Hermes 当前配置
+///
+/// 从 config.yaml 的 custom_providers 列表中获取当前 provider 的配置。
+/// 当前 provider 由 model.provider 字段指定。
+/// 返回 JSON `{model, base_url, api_key, provider_name}`
+pub fn read_hermes_live_settings() -> Result {
+ let yaml = read_hermes_config()?;
+
+ // 获取当前 model 设置
+ let model_default = yaml
+ .get("model")
+ .and_then(|m| m.get("default"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let model_provider = yaml
+ .get("model")
+ .and_then(|m| m.get("provider"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ // 从 custom_providers 列表中查找当前 provider
+ let custom_providers = yaml.get("custom_providers").and_then(|p| p.as_sequence());
+
+ let mut base_url = String::new();
+ let mut api_key = String::new();
+
+ if let Some(providers) = custom_providers {
+ for provider in providers {
+ if let Some(name) = provider.get("name").and_then(|n| n.as_str()) {
+ if name == model_provider {
+ base_url = provider
+ .get("base_url")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ api_key = provider
+ .get("api_key")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ break;
+ }
+ }
+ }
+ }
+
+ Ok(serde_json::json!({
+ "model": model_default,
+ "base_url": base_url,
+ "api_key": api_key,
+ "provider_name": model_provider,
+ }))
+}
+
+/// 将 Provider 配置写入 Hermes 的 config.yaml
+///
+/// Hermes 使用 custom_providers 列表存储 provider 配置。
+/// 更新 model.provider、model.default 指向当前 provider,
+/// 并更新或添加 custom_providers 中的对应条目。
+pub fn write_hermes_live(provider: &Provider) -> Result<(), AppError> {
+ log::info!(
+ "[hermes_config] write_hermes_live: provider_id={}, provider_name={}",
+ provider.id,
+ provider.name
+ );
+
+ let mut yaml = read_hermes_config()?;
+
+ // Ensure yaml is a mapping
+ if !yaml.is_mapping() {
+ yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
+ }
+
+ let settings = &provider.settings_config;
+
+ let model_str = settings
+ .get("model")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let base_url_str = settings
+ .get("base_url")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let api_key_str = settings
+ .get("api_key")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+
+ let provider_name = provider.name.clone();
+
+ // 1. 更新 model section
+ {
+ let mapping = yaml.as_mapping_mut().expect("yaml is a mapping");
+ let model_key = serde_yaml::Value::String("model".to_string());
+
+ let model_section = mapping
+ .entry(model_key)
+ .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
+
+ if let Some(model_map) = model_section.as_mapping_mut() {
+ // 更新 default model
+ if !model_str.is_empty() {
+ model_map.insert(
+ serde_yaml::Value::String("default".to_string()),
+ serde_yaml::Value::String(model_str.clone()),
+ );
+ }
+ // 更新 provider name
+ model_map.insert(
+ serde_yaml::Value::String("provider".to_string()),
+ serde_yaml::Value::String(provider_name.clone()),
+ );
+ }
+ }
+
+ // 2. 更新或添加 custom_providers
+ {
+ let mapping = yaml.as_mapping_mut().expect("yaml is a mapping");
+ let providers_key = serde_yaml::Value::String("custom_providers".to_string());
+
+ // 确保 custom_providers 存在
+ if !mapping.contains_key(&providers_key) {
+ mapping.insert(providers_key.clone(), serde_yaml::Value::Sequence(vec![]));
+ }
+
+ let providers = mapping
+ .get_mut(&providers_key)
+ .expect("custom_providers exists");
+
+ if let Some(providers_seq) = providers.as_sequence_mut() {
+ // 查找是否已存在同名 provider
+ let mut found = false;
+ for existing_provider in providers_seq.iter_mut() {
+ if let Some(existing_name) = existing_provider.get("name").and_then(|n| n.as_str())
+ {
+ if existing_name == provider_name {
+ // 更新现有 provider
+ if let Some(provider_map) = existing_provider.as_mapping_mut() {
+ if !base_url_str.is_empty() {
+ provider_map.insert(
+ serde_yaml::Value::String("base_url".to_string()),
+ serde_yaml::Value::String(base_url_str.clone()),
+ );
+ }
+ if !api_key_str.is_empty() {
+ provider_map.insert(
+ serde_yaml::Value::String("api_key".to_string()),
+ serde_yaml::Value::String(api_key_str.clone()),
+ );
+ }
+ if !model_str.is_empty() {
+ provider_map.insert(
+ serde_yaml::Value::String("model".to_string()),
+ serde_yaml::Value::String(model_str.clone()),
+ );
+ }
+ // 确保 transport 字段存在
+ if !provider_map
+ .contains_key(serde_yaml::Value::String("transport".to_string()))
+ {
+ provider_map.insert(
+ serde_yaml::Value::String("transport".to_string()),
+ serde_yaml::Value::String("openai_chat".to_string()),
+ );
+ }
+ }
+ found = true;
+ break;
+ }
+ }
+ }
+
+ // 如果不存在,添加新的 provider
+ if !found {
+ let mut new_provider = serde_yaml::Mapping::new();
+ new_provider.insert(
+ serde_yaml::Value::String("name".to_string()),
+ serde_yaml::Value::String(provider_name.clone()),
+ );
+ if !base_url_str.is_empty() {
+ new_provider.insert(
+ serde_yaml::Value::String("base_url".to_string()),
+ serde_yaml::Value::String(base_url_str),
+ );
+ }
+ if !api_key_str.is_empty() {
+ new_provider.insert(
+ serde_yaml::Value::String("api_key".to_string()),
+ serde_yaml::Value::String(api_key_str),
+ );
+ }
+ if !model_str.is_empty() {
+ new_provider.insert(
+ serde_yaml::Value::String("model".to_string()),
+ serde_yaml::Value::String(model_str),
+ );
+ }
+ new_provider.insert(
+ serde_yaml::Value::String("transport".to_string()),
+ serde_yaml::Value::String("openai_chat".to_string()),
+ );
+ providers_seq.push(serde_yaml::Value::Mapping(new_provider));
+ }
+ }
+ }
+
+ write_hermes_config_atomic(&yaml)?;
+
+ log::info!("[hermes_config] write_hermes_live: done");
+ Ok(())
+}
+
+/// 从 Hermes config.yaml 的 custom_providers 中移除指定 provider
+///
+/// 仅删除 custom_providers 列表中的条目,不修改 model section。
+/// 如果 provider 不存在,静默返回成功。
+pub fn remove_hermes_provider_from_live(provider_name: &str) -> Result<(), AppError> {
+ log::info!(
+ "[hermes_config] remove_hermes_provider_from_live: provider_name={}",
+ provider_name
+ );
+
+ // 检查 Hermes 配置目录是否存在
+ if !get_hermes_dir().exists() {
+ log::debug!("Hermes config directory doesn't exist, skipping removal");
+ return Ok(());
+ }
+
+ let mut yaml = read_hermes_config()?;
+
+ // 确保 yaml 是一个 mapping
+ if !yaml.is_mapping() {
+ return Ok(());
+ }
+
+ let mapping = yaml.as_mapping_mut().expect("yaml is a mapping");
+ let providers_key = serde_yaml::Value::String("custom_providers".to_string());
+
+ // 检查 custom_providers 是否存在
+ let Some(providers) = mapping.get_mut(&providers_key) else {
+ return Ok(());
+ };
+
+ let Some(providers_seq) = providers.as_sequence_mut() else {
+ return Ok(());
+ };
+
+ // 查找并移除指定 provider
+ let initial_len = providers_seq.len();
+ providers_seq.retain(|p| {
+ p.get("name")
+ .and_then(|n| n.as_str())
+ .is_none_or(|n| n != provider_name)
+ });
+
+ if providers_seq.len() < initial_len {
+ log::info!(
+ "[hermes_config] Removed provider '{}' from custom_providers",
+ provider_name
+ );
+ write_hermes_config_atomic(&yaml)?;
+ } else {
+ log::debug!(
+ "[hermes_config] Provider '{}' not found in custom_providers",
+ provider_name
+ );
+ }
+
+ Ok(())
+}
+
+/// 仅更新 Hermes config.yaml 的 model section(切换当前 provider)
+///
+/// 更新 model.provider 和 model.default,不修改 custom_providers 列表。
+/// 用于切换当前激活的 provider。
+#[allow(dead_code)]
+pub fn set_current_hermes_provider(
+ provider_name: &str,
+ model: Option<&str>,
+) -> Result<(), AppError> {
+ log::info!(
+ "[hermes_config] set_current_hermes_provider: provider_name={}, model={:?}",
+ provider_name,
+ model
+ );
+
+ // 检查 Hermes 配置目录是否存在
+ if !get_hermes_dir().exists() {
+ log::debug!("Hermes config directory doesn't exist, skipping set current");
+ return Ok(());
+ }
+
+ let mut yaml = read_hermes_config()?;
+
+ // 确保 yaml 是一个 mapping
+ if !yaml.is_mapping() {
+ yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
+ }
+
+ let mapping = yaml.as_mapping_mut().expect("yaml is a mapping");
+ let model_key = serde_yaml::Value::String("model".to_string());
+
+ let model_section = mapping
+ .entry(model_key)
+ .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new()));
+
+ if let Some(model_map) = model_section.as_mapping_mut() {
+ // 更新 provider name
+ model_map.insert(
+ serde_yaml::Value::String("provider".to_string()),
+ serde_yaml::Value::String(provider_name.to_string()),
+ );
+
+ // 如果提供了 model,也更新 default
+ if let Some(model_str) = model {
+ if !model_str.is_empty() {
+ model_map.insert(
+ serde_yaml::Value::String("default".to_string()),
+ serde_yaml::Value::String(model_str.to_string()),
+ );
+ }
+ }
+ }
+
+ write_hermes_config_atomic(&yaml)?;
+
+ log::info!("[hermes_config] set_current_hermes_provider: done");
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serial_test::serial;
+ use std::env;
+
+ #[test]
+ #[serial]
+ fn test_read_write_hermes_config() {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let old_test_home = env::var_os("CC_SWITCH_TEST_HOME");
+ env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
+
+ let mut yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
+ let mut model_map = serde_yaml::Mapping::new();
+ model_map.insert(
+ serde_yaml::Value::String("default".to_string()),
+ serde_yaml::Value::String("claude-sonnet-4-6".to_string()),
+ );
+ model_map.insert(
+ serde_yaml::Value::String("provider".to_string()),
+ serde_yaml::Value::String("api-proxy-claude".to_string()),
+ );
+ yaml.as_mapping_mut().unwrap().insert(
+ serde_yaml::Value::String("model".to_string()),
+ serde_yaml::Value::Mapping(model_map),
+ );
+
+ let mut custom_providers = vec![];
+ let mut provider_map = serde_yaml::Mapping::new();
+ provider_map.insert(
+ serde_yaml::Value::String("name".to_string()),
+ serde_yaml::Value::String("api-proxy-claude".to_string()),
+ );
+ provider_map.insert(
+ serde_yaml::Value::String("base_url".to_string()),
+ serde_yaml::Value::String("https://api.example.com/v1".to_string()),
+ );
+ provider_map.insert(
+ serde_yaml::Value::String("api_key".to_string()),
+ serde_yaml::Value::String("sk-test-key".to_string()),
+ );
+ provider_map.insert(
+ serde_yaml::Value::String("model".to_string()),
+ serde_yaml::Value::String("claude-sonnet-4-6".to_string()),
+ );
+ provider_map.insert(
+ serde_yaml::Value::String("transport".to_string()),
+ serde_yaml::Value::String("openai_chat".to_string()),
+ );
+ custom_providers.push(serde_yaml::Value::Mapping(provider_map));
+ yaml.as_mapping_mut().unwrap().insert(
+ serde_yaml::Value::String("custom_providers".to_string()),
+ serde_yaml::Value::Sequence(custom_providers),
+ );
+
+ write_hermes_config_atomic(&yaml).unwrap();
+ let read_yaml = read_hermes_config().unwrap();
+
+ assert_eq!(
+ read_yaml
+ .get("model")
+ .and_then(|m| m.get("default"))
+ .and_then(|v| v.as_str()),
+ Some("claude-sonnet-4-6")
+ );
+ assert_eq!(
+ read_yaml
+ .get("model")
+ .and_then(|m| m.get("provider"))
+ .and_then(|v| v.as_str()),
+ Some("api-proxy-claude")
+ );
+
+ // Verify custom_providers
+ let providers = read_yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert_eq!(providers.len(), 1);
+ let first_provider = &providers[0];
+ assert_eq!(
+ first_provider.get("name").and_then(|n| n.as_str()),
+ Some("api-proxy-claude")
+ );
+ assert_eq!(
+ first_provider.get("base_url").and_then(|v| v.as_str()),
+ Some("https://api.example.com/v1")
+ );
+
+ match old_test_home {
+ Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
+ None => env::remove_var("CC_SWITCH_TEST_HOME"),
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_write_hermes_env_preserves_other_keys() {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let old_test_home = env::var_os("CC_SWITCH_TEST_HOME");
+ env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
+
+ let mut env: HashMap = HashMap::new();
+ env.insert("TELEGRAM_BOT_TOKEN".to_string(), "test-token".to_string());
+ env.insert("OTHER_KEY".to_string(), "other-value".to_string());
+
+ write_hermes_env_atomic(&env).unwrap();
+
+ let read_env = read_hermes_env().unwrap();
+ assert_eq!(
+ read_env.get("TELEGRAM_BOT_TOKEN"),
+ Some(&"test-token".to_string())
+ );
+ assert_eq!(read_env.get("OTHER_KEY"), Some(&"other-value".to_string()));
+
+ match old_test_home {
+ Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
+ None => env::remove_var("CC_SWITCH_TEST_HOME"),
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_write_hermes_live() {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let old_test_home = env::var_os("CC_SWITCH_TEST_HOME");
+ env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
+
+ let provider = Provider::with_id(
+ "test-1".to_string(),
+ "My Custom Provider".to_string(),
+ serde_json::json!({
+ "model": "claude-sonnet-4-6",
+ "base_url": "https://api.custom.com/v1",
+ "api_key": "sk-custom-key",
+ }),
+ None,
+ );
+
+ write_hermes_live(&provider).unwrap();
+
+ let yaml = read_hermes_config().unwrap();
+
+ // 验证 model section
+ assert_eq!(
+ yaml.get("model")
+ .and_then(|m| m.get("default"))
+ .and_then(|v| v.as_str()),
+ Some("claude-sonnet-4-6")
+ );
+ assert_eq!(
+ yaml.get("model")
+ .and_then(|m| m.get("provider"))
+ .and_then(|v| v.as_str()),
+ Some("My Custom Provider")
+ );
+
+ // 验证 custom_providers
+ let providers = yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert!(providers.len() >= 1);
+ let found = providers
+ .iter()
+ .any(|p| p.get("name").and_then(|n| n.as_str()) == Some("My Custom Provider"));
+ assert!(
+ found,
+ "custom_providers should contain 'My Custom Provider'"
+ );
+
+ match old_test_home {
+ Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
+ None => env::remove_var("CC_SWITCH_TEST_HOME"),
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_remove_hermes_provider_from_live() {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let old_test_home = env::var_os("CC_SWITCH_TEST_HOME");
+ env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
+
+ // 创建两个 provider
+ let provider1 = Provider::with_id(
+ "test-1".to_string(),
+ "Provider One".to_string(),
+ serde_json::json!({
+ "model": "claude-sonnet-4-6",
+ "base_url": "https://api.one.com/v1",
+ "api_key": "sk-key-one",
+ }),
+ None,
+ );
+
+ let provider2 = Provider::with_id(
+ "test-2".to_string(),
+ "Provider Two".to_string(),
+ serde_json::json!({
+ "model": "claude-opus-4",
+ "base_url": "https://api.two.com/v1",
+ "api_key": "sk-key-two",
+ }),
+ None,
+ );
+
+ // 写入两个 provider
+ write_hermes_live(&provider1).unwrap();
+ write_hermes_live(&provider2).unwrap();
+
+ // 验证两个 provider 都存在
+ let yaml = read_hermes_config().unwrap();
+ let providers = yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert_eq!(providers.len(), 2);
+
+ // 删除第一个 provider
+ remove_hermes_provider_from_live("Provider One").unwrap();
+
+ // 验证只剩一个 provider
+ let yaml = read_hermes_config().unwrap();
+ let providers = yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert_eq!(providers.len(), 1);
+
+ // 验证剩余的是 Provider Two
+ let remaining_name = providers[0].get("name").and_then(|n| n.as_str()).unwrap();
+ assert_eq!(remaining_name, "Provider Two");
+
+ // 验证删除不存在的 provider 不会出错
+ remove_hermes_provider_from_live("Non-existent Provider").unwrap();
+
+ // 验证 provider 数量不变
+ let yaml = read_hermes_config().unwrap();
+ let providers = yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert_eq!(providers.len(), 1);
+
+ match old_test_home {
+ Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
+ None => env::remove_var("CC_SWITCH_TEST_HOME"),
+ }
+ }
+
+ #[test]
+ #[serial]
+ fn test_set_current_hermes_provider() {
+ let tmp = tempfile::tempdir().expect("create temp dir");
+ let old_test_home = env::var_os("CC_SWITCH_TEST_HOME");
+ env::set_var("CC_SWITCH_TEST_HOME", tmp.path());
+
+ // 创建一个 provider
+ let provider = Provider::with_id(
+ "test-1".to_string(),
+ "Test Provider".to_string(),
+ serde_json::json!({
+ "model": "claude-sonnet-4-6",
+ "base_url": "https://api.test.com/v1",
+ "api_key": "sk-test-key",
+ }),
+ None,
+ );
+ write_hermes_live(&provider).unwrap();
+
+ // 切换到不同的 provider(仅更新 model section)
+ set_current_hermes_provider("Another Provider", Some("claude-opus-4")).unwrap();
+
+ let yaml = read_hermes_config().unwrap();
+
+ // 验证 model section 已更新
+ assert_eq!(
+ yaml.get("model")
+ .and_then(|m| m.get("provider"))
+ .and_then(|v| v.as_str()),
+ Some("Another Provider")
+ );
+ assert_eq!(
+ yaml.get("model")
+ .and_then(|m| m.get("default"))
+ .and_then(|v| v.as_str()),
+ Some("claude-opus-4")
+ );
+
+ // 验证 custom_providers 仍然只有一个(没有被修改)
+ let providers = yaml
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence())
+ .unwrap();
+ assert_eq!(providers.len(), 1);
+
+ match old_test_home {
+ Some(value) => env::set_var("CC_SWITCH_TEST_HOME", value),
+ None => env::remove_var("CC_SWITCH_TEST_HOME"),
+ }
+ }
+}
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index dbfb63eae1..e6c02113bb 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -11,6 +11,7 @@ mod deeplink;
mod error;
mod gemini_config;
mod gemini_mcp;
+mod hermes_config;
mod init_status;
mod lightweight;
#[cfg(target_os = "linux")]
@@ -40,11 +41,13 @@ pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file};
pub use database::Database;
pub use deeplink::{import_provider_from_deeplink, parse_deeplink_url, DeepLinkImportRequest};
pub use error::AppError;
+pub use hermes_config::{get_hermes_config_path, get_hermes_env_path};
pub use mcp::{
- import_from_claude, import_from_codex, import_from_gemini, remove_server_from_claude,
- remove_server_from_codex, remove_server_from_gemini, sync_enabled_to_claude,
- sync_enabled_to_codex, sync_enabled_to_gemini, sync_single_server_to_claude,
- sync_single_server_to_codex, sync_single_server_to_gemini,
+ import_from_claude, import_from_codex, import_from_gemini, import_from_hermes,
+ remove_server_from_claude, remove_server_from_codex, remove_server_from_gemini,
+ remove_server_from_hermes, sync_enabled_to_claude, sync_enabled_to_codex,
+ sync_enabled_to_gemini, sync_enabled_to_hermes, sync_single_server_to_claude,
+ sync_single_server_to_codex, sync_single_server_to_gemini, sync_single_server_to_hermes,
};
pub use provider::{Provider, ProviderMeta};
pub use services::{
@@ -627,6 +630,14 @@ pub fn run() {
Ok(_) => log::debug!("○ No OpenCode MCP servers found to import"),
Err(e) => log::warn!("✗ Failed to import OpenCode MCP: {e}"),
}
+
+ match crate::services::mcp::McpService::import_from_hermes(&app_state) {
+ Ok(count) if count > 0 => {
+ log::info!("✓ Imported {count} MCP server(s) from Hermes");
+ }
+ Ok(_) => log::debug!("○ No Hermes MCP servers found to import"),
+ Err(e) => log::warn!("✗ Failed to import Hermes MCP: {e}"),
+ }
}
// 4. 导入提示词文件(表空时触发)
diff --git a/src-tauri/src/mcp/claude.rs b/src-tauri/src/mcp/claude.rs
index 25d2f426a3..e1971fb73b 100644
--- a/src-tauri/src/mcp/claude.rs
+++ b/src-tauri/src/mcp/claude.rs
@@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result Result
codex: true,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
diff --git a/src-tauri/src/mcp/gemini.rs b/src-tauri/src/mcp/gemini.rs
index c8a7809f35..73c2fc8403 100644
--- a/src-tauri/src/mcp/gemini.rs
+++ b/src-tauri/src/mcp/gemini.rs
@@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result bool {
+ // Hermes 未安装/未初始化时:~/.hermes 目录不存在。
+ // 按用户偏好:目录缺失时跳过写入/删除,不创建任何文件或目录。
+ crate::hermes_config::get_hermes_dir().exists()
+}
+
+/// 返回已启用的 MCP 服务器(过滤 enabled==true)
+fn collect_enabled_servers(cfg: &McpConfig) -> HashMap {
+ let mut out = HashMap::new();
+ for (id, entry) in cfg.servers.iter() {
+ let enabled = entry
+ .get("enabled")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ if !enabled {
+ continue;
+ }
+ match extract_server_spec(entry) {
+ Ok(spec) => {
+ out.insert(id.clone(), spec);
+ }
+ Err(err) => {
+ log::warn!("跳过无效的 MCP 条目 '{id}': {err}");
+ }
+ }
+ }
+ out
+}
+
+/// 从 Hermes config.yaml 中读取 mcp_servers 映射
+///
+/// Hermes 使用 YAML 格式,mcp_servers 是一个 mapping。
+/// 返回的 Value 是 serde_json 格式,便于与其他模块统一处理。
+fn read_hermes_mcp_servers_map() -> Result, AppError> {
+ let yaml = read_hermes_config()?;
+
+ let mcp_servers = yaml.get("mcp_servers").and_then(|v| v.as_mapping());
+
+ if let Some(servers_map) = mcp_servers {
+ let mut result = HashMap::new();
+ for (key, val) in servers_map.iter() {
+ if let Some(key_str) = key.as_str() {
+ // 将 serde_yaml::Value 转换为 serde_json::Value
+ let json_val = serde_json::to_value(val).map_err(|e| {
+ AppError::localized(
+ "hermes.mcp.convert_error",
+ format!("Hermes MCP 配置转换失败: {e}"),
+ format!("Failed to convert Hermes MCP config: {e}"),
+ )
+ })?;
+ result.insert(key_str.to_string(), json_val);
+ }
+ }
+ return Ok(result);
+ }
+
+ Ok(HashMap::new())
+}
+
+/// 将 MCP 服务器映射写入 Hermes config.yaml 的 mcp_servers 字段
+fn set_hermes_mcp_servers_map(servers: &HashMap) -> Result<(), AppError> {
+ let mut yaml = read_hermes_config()?;
+
+ // 确保 yaml 是一个 mapping
+ if !yaml.is_mapping() {
+ yaml = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
+ }
+
+ let mapping = yaml.as_mapping_mut().expect("yaml is a mapping");
+ let mcp_key = serde_yaml::Value::String("mcp_servers".to_string());
+
+ // 构建 mcp_servers mapping
+ let mut mcp_map = serde_yaml::Mapping::new();
+ for (id, spec) in servers.iter() {
+ // 将 serde_json::Value 转换为 serde_yaml::Value
+ let yaml_val = serde_yaml::to_value(spec).map_err(|e| {
+ AppError::localized(
+ "hermes.mcp.convert_error",
+ format!("Hermes MCP 配置转换失败: {e}"),
+ format!("Failed to convert Hermes MCP config: {e}"),
+ )
+ })?;
+ mcp_map.insert(serde_yaml::Value::String(id.clone()), yaml_val);
+ }
+
+ mapping.insert(mcp_key, serde_yaml::Value::Mapping(mcp_map));
+
+ write_hermes_config_atomic(&yaml)
+}
+
+/// 将 config.json 中 Hermes 的 enabled==true 项写入 Hermes MCP 配置
+pub fn sync_enabled_to_hermes(config: &MultiAppConfig) -> Result<(), AppError> {
+ if !should_sync_hermes_mcp() {
+ return Ok(());
+ }
+ let enabled = collect_enabled_servers(&config.mcp.hermes);
+ set_hermes_mcp_servers_map(&enabled)
+}
+
+/// 从 Hermes MCP 配置导入到统一结构
+/// 已存在的服务器将启用 Hermes 应用,不覆盖其他字段和应用状态
+pub fn import_from_hermes(config: &mut MultiAppConfig) -> Result {
+ let map = read_hermes_mcp_servers_map()?;
+ if map.is_empty() {
+ return Ok(0);
+ }
+
+ // 确保新结构存在
+ let servers = config.mcp.servers.get_or_insert_with(HashMap::new);
+
+ let mut changed = 0;
+ let mut errors = Vec::new();
+
+ for (id, spec) in map.iter() {
+ // 校验:单项失败不中止,收集错误继续处理
+ if let Err(e) = validate_server_spec(spec) {
+ log::warn!("跳过无效 MCP 服务器 '{id}': {e}");
+ errors.push(format!("{id}: {e}"));
+ continue;
+ }
+
+ if let Some(existing) = servers.get_mut(id) {
+ // 已存在:仅启用 Hermes 应用
+ if !existing.apps.hermes {
+ existing.apps.hermes = true;
+ changed += 1;
+ log::info!("MCP 服务器 '{id}' 已启用 Hermes 应用");
+ }
+ } else {
+ // 新建服务器:默认仅启用 Hermes
+ servers.insert(
+ id.clone(),
+ McpServer {
+ id: id.clone(),
+ name: id.clone(),
+ server: spec.clone(),
+ apps: McpApps {
+ claude: false,
+ codex: false,
+ gemini: false,
+ opencode: false,
+ hermes: true,
+ },
+ description: None,
+ homepage: None,
+ docs: None,
+ tags: Vec::new(),
+ },
+ );
+ changed += 1;
+ log::info!("导入新 MCP 服务器 '{id}'");
+ }
+ }
+
+ if !errors.is_empty() {
+ log::warn!("导入完成,但有 {} 项失败: {:?}", errors.len(), errors);
+ }
+
+ Ok(changed)
+}
+
+/// 将单个 MCP 服务器同步到 Hermes live 配置
+pub fn sync_single_server_to_hermes(
+ _config: &MultiAppConfig,
+ id: &str,
+ server_spec: &Value,
+) -> Result<(), AppError> {
+ if !should_sync_hermes_mcp() {
+ return Ok(());
+ }
+ // 读取现有的 MCP 配置
+ let mut current = read_hermes_mcp_servers_map()?;
+
+ // 添加/更新当前服务器
+ current.insert(id.to_string(), server_spec.clone());
+
+ // 写回
+ set_hermes_mcp_servers_map(¤t)
+}
+
+/// 从 Hermes live 配置中移除单个 MCP 服务器
+pub fn remove_server_from_hermes(id: &str) -> Result<(), AppError> {
+ if !should_sync_hermes_mcp() {
+ return Ok(());
+ }
+ // 读取现有的 MCP 配置
+ let mut current = read_hermes_mcp_servers_map()?;
+
+ // 移除指定服务器
+ current.remove(id);
+
+ // 写回
+ set_hermes_mcp_servers_map(¤t)
+}
diff --git a/src-tauri/src/mcp/mod.rs b/src-tauri/src/mcp/mod.rs
index 2f2b7f424e..b88efb2829 100644
--- a/src-tauri/src/mcp/mod.rs
+++ b/src-tauri/src/mcp/mod.rs
@@ -8,11 +8,13 @@
//! - `claude` - Claude MCP 同步和导入
//! - `codex` - Codex MCP 同步和导入(含 TOML 转换)
//! - `gemini` - Gemini MCP 同步和导入
+//! - `hermes` - Hermes MCP 同步和导入
//! - `opencode` - OpenCode MCP 同步和导入(含 local/remote 格式转换)
mod claude;
mod codex;
mod gemini;
+mod hermes;
mod opencode;
mod validation;
@@ -28,6 +30,10 @@ pub use gemini::{
import_from_gemini, remove_server_from_gemini, sync_enabled_to_gemini,
sync_single_server_to_gemini,
};
+pub use hermes::{
+ import_from_hermes, remove_server_from_hermes, sync_enabled_to_hermes,
+ sync_single_server_to_hermes,
+};
pub use opencode::{
import_from_opencode, remove_server_from_opencode, sync_single_server_to_opencode,
};
diff --git a/src-tauri/src/mcp/opencode.rs b/src-tauri/src/mcp/opencode.rs
index 687d5797c7..36dd25b57f 100644
--- a/src-tauri/src/mcp/opencode.rs
+++ b/src-tauri/src/mcp/opencode.rs
@@ -259,6 +259,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result Result {
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
+ AppType::Hermes => crate::config::get_home_dir().join(".hermes"),
};
let filename = match app {
@@ -23,7 +24,8 @@ pub fn prompt_file_path(app: &AppType) -> Result {
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
- AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
+ AppType::OpenClaw => "AGENTS.md",
+ AppType::Hermes => "AGENTS.md",
};
Ok(base_dir.join(filename))
diff --git a/src-tauri/src/proxy/providers/mod.rs b/src-tauri/src/proxy/providers/mod.rs
index b6b8bb6433..c0b7054d67 100644
--- a/src-tauri/src/proxy/providers/mod.rs
+++ b/src-tauri/src/proxy/providers/mod.rs
@@ -170,6 +170,10 @@ impl ProviderType {
// OpenClaw doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy yet, fallback to Codex-like type
+ ProviderType::Codex
+ }
}
}
@@ -228,6 +232,10 @@ pub fn get_adapter(app_type: &AppType) -> Box {
// OpenClaw doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy yet, fallback to Codex adapter
+ Box::new(CodexAdapter::new())
+ }
}
}
diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs
index d354cccb13..650e9c0a82 100644
--- a/src-tauri/src/services/config.rs
+++ b/src-tauri/src/services/config.rs
@@ -130,6 +130,7 @@ impl ConfigService {
// OpenClaw uses additive mode, no live sync needed
// OpenClaw providers are managed directly in the config file
}
+ AppType::Hermes => Self::sync_hermes_live(config, ¤t_id, &provider)?,
}
Ok(())
@@ -228,4 +229,22 @@ impl ConfigService {
Ok(())
}
+
+ fn sync_hermes_live(
+ config: &mut MultiAppConfig,
+ provider_id: &str,
+ provider: &Provider,
+ ) -> Result<(), AppError> {
+ crate::hermes_config::write_hermes_live(provider)?;
+
+ // 读回实际写入的内容并更新到配置中
+ let live_after = crate::hermes_config::read_hermes_live_settings()?;
+ if let Some(manager) = config.get_manager_mut(&AppType::Hermes) {
+ if let Some(target) = manager.providers.get_mut(provider_id) {
+ target.settings_config = live_after;
+ }
+ }
+
+ Ok(())
+ }
}
diff --git a/src-tauri/src/services/mcp.rs b/src-tauri/src/services/mcp.rs
index 02b31aea93..d8aaa9320d 100644
--- a/src-tauri/src/services/mcp.rs
+++ b/src-tauri/src/services/mcp.rs
@@ -40,6 +40,9 @@ impl McpService {
if prev_apps.opencode && !server.apps.opencode {
Self::remove_server_from_app(state, &server.id, &AppType::OpenCode)?;
}
+ if prev_apps.hermes && !server.apps.hermes {
+ Self::remove_server_from_app(state, &server.id, &AppType::Hermes)?;
+ }
// 同步到各个启用的应用
Self::sync_server_to_apps(state, &server)?;
@@ -128,6 +131,9 @@ impl McpService {
// Skip for now
log::debug!("OpenClaw MCP support is still in development, skipping sync");
}
+ AppType::Hermes => {
+ mcp::sync_single_server_to_hermes(&Default::default(), &server.id, &server.server)?;
+ }
}
Ok(())
}
@@ -157,6 +163,9 @@ impl McpService {
// OpenClaw MCP support is still in development
log::debug!("OpenClaw MCP support is still in development, skipping remove");
}
+ AppType::Hermes => {
+ mcp::remove_server_from_hermes(id)?;
+ }
}
Ok(())
}
@@ -381,4 +390,42 @@ impl McpService {
Ok(new_count)
}
+
+ /// 从 Hermes 导入 MCP(v3.13.0+ 新增)
+ pub fn import_from_hermes(state: &AppState) -> Result {
+ // 创建临时 MultiAppConfig 用于导入
+ let mut temp_config = crate::app_config::MultiAppConfig::default();
+
+ // 调用原有的导入逻辑(从 mcp/hermes.rs)
+ let count = crate::mcp::import_from_hermes(&mut temp_config)?;
+
+ let mut new_count = 0;
+
+ // 如果有导入的服务器,保存到数据库
+ if count > 0 {
+ if let Some(servers) = &temp_config.mcp.servers {
+ let mut existing = state.db.get_all_mcp_servers()?;
+ for server in servers.values() {
+ // 已存在:仅启用 Hermes,不覆盖其他字段(与导入模块语义保持一致)
+ let to_save = if let Some(existing_server) = existing.get(&server.id) {
+ let mut merged = existing_server.clone();
+ merged.apps.hermes = true;
+ merged
+ } else {
+ // 真正的新服务器
+ new_count += 1;
+ server.clone()
+ };
+
+ state.db.save_mcp_server(&to_save)?;
+ existing.insert(to_save.id.clone(), to_save.clone());
+
+ // 同步到对应应用 live 配置
+ Self::sync_server_to_apps(state, &to_save)?;
+ }
+ }
+ }
+
+ Ok(new_count)
+ }
}
diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs
index 21147da650..be81b174a2 100644
--- a/src-tauri/src/services/provider/live.rs
+++ b/src-tauri/src/services/provider/live.rs
@@ -332,7 +332,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
toml_item_is_subset(target_doc.as_item(), source_doc.as_item())
}
- AppType::Gemini => match serde_json::from_str::(trimmed) {
+ AppType::Gemini | AppType::Hermes => match serde_json::from_str::(trimmed) {
Ok(Value::Object(source_map)) => {
let Some(target_map) = settings.get("env").and_then(Value::as_object) else {
return false;
@@ -406,9 +406,17 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
- AppType::Gemini => {
- let source = serde_json::from_str::(trimmed)
- .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
+ AppType::Gemini | AppType::Hermes => {
+ let source = serde_json::from_str::(trimmed).map_err(|e| {
+ AppError::Message(format!(
+ "Invalid {} common config: {e}",
+ if matches!(app_type, AppType::Gemini) {
+ "Gemini"
+ } else {
+ "Hermes"
+ }
+ ))
+ })?;
let mut result = settings.clone();
if let Some(env) = result.get_mut("env") {
json_deep_remove(env, &source);
@@ -459,9 +467,17 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
- AppType::Gemini => {
- let source = serde_json::from_str::(trimmed)
- .map_err(|e| AppError::Message(format!("Invalid Gemini common config: {e}")))?;
+ AppType::Gemini | AppType::Hermes => {
+ let source = serde_json::from_str::(trimmed).map_err(|e| {
+ AppError::Message(format!(
+ "Invalid {} common config: {e}",
+ if matches!(app_type, AppType::Gemini) {
+ "Gemini"
+ } else {
+ "Hermes"
+ }
+ ))
+ })?;
let mut result = settings.clone();
if let Some(env) = result.get_mut("env") {
json_deep_merge(env, &source);
@@ -603,6 +619,10 @@ pub(crate) enum LiveSnapshot {
env: Option>,
config: Option,
},
+ Hermes {
+ config: Option,
+ env: Option>,
+ },
}
impl LiveSnapshot {
@@ -656,6 +676,25 @@ impl LiveSnapshot {
_ => {}
}
}
+ LiveSnapshot::Hermes { config, env } => {
+ use crate::hermes_config::{
+ get_hermes_config_path, get_hermes_env_path, write_hermes_config_atomic,
+ write_hermes_env_atomic,
+ };
+ let config_path = get_hermes_config_path();
+ if let Some(yaml) = config {
+ write_hermes_config_atomic(yaml)?;
+ } else if config_path.exists() {
+ delete_file(&config_path)?;
+ }
+
+ let env_path = get_hermes_env_path();
+ if let Some(env_map) = env {
+ write_hermes_env_atomic(env_map)?;
+ } else if env_path.exists() {
+ delete_file(&env_path)?;
+ }
+ }
}
Ok(())
}
@@ -790,6 +829,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
}
}
}
+ AppType::Hermes => {
+ log::info!(
+ "write_live_snapshot: writing Hermes provider '{}'",
+ provider.id
+ );
+ crate::hermes_config::write_hermes_live(provider)?;
+ }
}
Ok(())
}
@@ -985,6 +1031,7 @@ pub fn read_live_settings(app_type: AppType) -> Result {
let config = read_openclaw_config()?;
Ok(config)
}
+ AppType::Hermes => crate::hermes_config::read_hermes_live_settings(),
}
}
@@ -1071,14 +1118,99 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result {
unreachable!("additive mode apps are handled by early return")
}
+ AppType::Hermes => {
+ use crate::hermes_config::{get_hermes_config_path, read_hermes_config};
+
+ // Check if config.yaml exists
+ let config_path = get_hermes_config_path();
+ if !config_path.exists() {
+ return Err(AppError::localized(
+ "hermes.live.missing",
+ "Hermes 配置文件不存在",
+ "Hermes configuration file is missing",
+ ));
+ }
+
+ // Read config.yaml
+ let yaml_config = read_hermes_config()?;
+
+ // Get current provider name from model.provider
+ let current_provider_name = yaml_config
+ .get("model")
+ .and_then(|m| m.get("provider"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ // Get current model from model.default
+ let model_default = yaml_config
+ .get("model")
+ .and_then(|m| m.get("default"))
+ .and_then(|v| v.as_str())
+ .unwrap_or("");
+
+ // Find the current provider in custom_providers list
+ let custom_providers = yaml_config
+ .get("custom_providers")
+ .and_then(|p| p.as_sequence());
+
+ let (base_url, api_key, provider_model) = if let Some(providers) = custom_providers {
+ let mut found_base_url = String::new();
+ let mut found_api_key = String::new();
+ let mut found_model = String::new();
+
+ for provider in providers {
+ if let Some(name) = provider.get("name").and_then(|n| n.as_str()) {
+ if name == current_provider_name {
+ found_base_url = provider
+ .get("base_url")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ found_api_key = provider
+ .get("api_key")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ found_model = provider
+ .get("model")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ break;
+ }
+ }
+ }
+
+ (found_base_url, found_api_key, found_model)
+ } else {
+ (String::new(), String::new(), String::new())
+ };
+
+ // Build settings_config using snake_case to match write_hermes_live format
+ json!({
+ "name": current_provider_name,
+ "model": if !provider_model.is_empty() { provider_model } else { model_default.to_string() },
+ "base_url": base_url,
+ "api_key": api_key,
+ "transport": "openai_chat"
+ })
+ }
};
- let mut provider = Provider::with_id(
- "default".to_string(),
- "default".to_string(),
- settings_config,
- None,
- );
+ // For Hermes, extract provider name from settings_config
+ let provider_name = if matches!(app_type, AppType::Hermes) {
+ settings_config
+ .get("name")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ .unwrap_or("default")
+ .to_string()
+ } else {
+ "default".to_string()
+ };
+
+ let mut provider =
+ Provider::with_id("default".to_string(), provider_name, settings_config, None);
provider.category = Some("custom".to_string());
state.db.save_provider(app_type.as_str(), &provider)?;
@@ -1190,6 +1322,16 @@ pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<()
Ok(())
}
+/// Remove a Hermes provider from the live configuration
+///
+/// Removes the provider from custom_providers list in ~/.hermes/config.yaml.
+/// Note: provider_name (display name) is used as the key, not provider_id.
+pub(crate) fn remove_hermes_provider_from_live(provider_name: &str) -> Result<(), AppError> {
+ crate::hermes_config::remove_hermes_provider_from_live(provider_name)?;
+ log::info!("Hermes provider '{provider_name}' removed from live config");
+ Ok(())
+}
+
/// Import all providers from OpenCode live config to database
///
/// This imports existing providers from ~/.config/opencode/opencode.json
diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs
index d16f180893..02bcdbd5e9 100644
--- a/src-tauri/src/services/provider/mod.rs
+++ b/src-tauri/src/services/provider/mod.rs
@@ -35,7 +35,8 @@ pub(crate) use live::{
// Internal re-exports
use live::{
- remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
+ remove_hermes_provider_from_live, remove_openclaw_provider_from_live,
+ remove_opencode_provider_from_live, write_gemini_live,
};
use usage::validate_usage_script;
@@ -1299,6 +1300,13 @@ impl ProviderService {
));
}
+ // For Hermes: remove from custom_providers in live config
+ if matches!(app_type, AppType::Hermes) {
+ if let Some(provider) = state.db.get_provider_by_id(id, app_type.as_str())? {
+ remove_hermes_provider_from_live(&provider.name)?;
+ }
+ }
+
state.db.delete_provider(app_type.as_str(), id)
}
@@ -1708,6 +1716,7 @@ impl ProviderService {
AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
+ AppType::Hermes => Self::extract_hermes_common_config(&provider.settings_config),
}
}
@@ -1722,6 +1731,7 @@ impl ProviderService {
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
+ AppType::Hermes => Self::extract_hermes_common_config(settings_config),
}
}
@@ -1899,6 +1909,41 @@ impl ProviderService {
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
}
+ /// Extract common config for Hermes (env-style, same pattern as Gemini)
+ fn extract_hermes_common_config(settings: &Value) -> Result {
+ let env = settings.get("env").and_then(|v| v.as_object());
+
+ let mut snippet = serde_json::Map::new();
+ if let Some(env) = env {
+ // Exclude provider-specific API key env vars
+ const HERMES_API_KEY_VARS: &[&str] = &[
+ "ANTHROPIC_API_KEY",
+ "OPENROUTER_API_KEY",
+ "OPENAI_API_KEY",
+ "HERMES_API_KEY",
+ ];
+ for (key, value) in env {
+ if HERMES_API_KEY_VARS.contains(&key.as_str()) {
+ continue;
+ }
+ let Value::String(v) = value else {
+ continue;
+ };
+ let trimmed = v.trim();
+ if !trimmed.is_empty() {
+ snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
+ }
+ }
+ }
+
+ if snippet.is_empty() {
+ return Ok("{}".to_string());
+ }
+
+ serde_json::to_string_pretty(&Value::Object(snippet))
+ .map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
+ }
+
/// Import default configuration from live files (re-export)
///
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
@@ -2087,6 +2132,16 @@ impl ProviderService {
));
}
}
+ AppType::Hermes => {
+ // Hermes validation not yet implemented
+ if !provider.settings_config.is_object() {
+ return Err(AppError::localized(
+ "provider.hermes.settings.not_object",
+ "Hermes 配置必须是 JSON 对象",
+ "Hermes configuration must be a JSON object",
+ ));
+ }
+ }
}
// Validate and clean UsageScript configuration (common for all app types)
@@ -2280,6 +2335,42 @@ impl ProviderService {
.unwrap_or("")
.to_string();
+ Ok((api_key, base_url))
+ }
+ AppType::Hermes => {
+ // Hermes uses env-style config (like Gemini)
+ // Extract env map from settings_config
+ let env_map = if let Some(env_obj) = provider
+ .settings_config
+ .get("env")
+ .and_then(|v| v.as_object())
+ {
+ env_obj
+ .iter()
+ .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
+ .collect::>()
+ } else {
+ std::collections::HashMap::new()
+ };
+
+ // Try multiple possible API key env vars (in priority order)
+ let api_key = env_map
+ .get("ANTHROPIC_API_KEY")
+ .or_else(|| env_map.get("OPENROUTER_API_KEY"))
+ .or_else(|| env_map.get("HERMES_API_KEY"))
+ .or_else(|| env_map.get("OPENAI_API_KEY"))
+ .cloned()
+ .ok_or_else(|| {
+ AppError::localized(
+ "hermes.missing_api_key",
+ "缺少 API Key(需要 ANTHROPIC_API_KEY、OPENROUTER_API_KEY 或 HERMES_API_KEY)",
+ "Missing API Key (need ANTHROPIC_API_KEY, OPENROUTER_API_KEY, or HERMES_API_KEY)",
+ )
+ })?;
+
+ // Base URL is optional for Hermes
+ let base_url = env_map.get("HERMES_BASE_URL").cloned().unwrap_or_default();
+
Ok((api_key, base_url))
}
}
diff --git a/src-tauri/src/services/proxy.rs b/src-tauri/src/services/proxy.rs
index 267d71ca79..d2e96f0647 100644
--- a/src-tauri/src/services/proxy.rs
+++ b/src-tauri/src/services/proxy.rs
@@ -474,6 +474,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features
+ return Err("Hermes 不支持代理功能".to_string());
+ }
};
self.sync_live_config_to_provider(app_type, &live_config)
@@ -693,6 +697,9 @@ impl ProxyService {
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features, skip silently
+ }
}
Ok(())
@@ -879,6 +886,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features
+ return Err("Hermes 不支持代理功能".to_string());
+ }
};
let json_str = serde_json::to_string(&config)
@@ -1027,6 +1038,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features
+ return Err("Hermes 不支持代理功能".to_string());
+ }
}
Ok(())
@@ -1082,6 +1097,9 @@ impl ProxyService {
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features, skip silently
+ }
}
Ok(())
@@ -1125,6 +1143,9 @@ impl ProxyService {
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features, skip silently
+ }
}
Ok(())
@@ -1221,6 +1242,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy features
Err("OpenClaw 不支持代理功能".to_string())
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features
+ Err("Hermes 不支持代理功能".to_string())
+ }
}
}
@@ -1246,6 +1271,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy takeover
false
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy takeover
+ false
+ }
}
}
@@ -1293,6 +1322,10 @@ impl ProxyService {
// OpenClaw doesn't support proxy features
Ok(())
}
+ AppType::Hermes => {
+ // Hermes doesn't support proxy features
+ Ok(())
+ }
}
}
@@ -1540,7 +1573,7 @@ impl ProxyService {
serde_json::to_string(&env_backup)
.map_err(|e| format!("序列化 Gemini 配置失败: {e}"))?
}
- AppType::OpenCode | AppType::OpenClaw => {
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
return Err(format!("未知的应用类型: {app_type}"));
}
};
diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs
index 2acbdfae9e..8fef7b5f43 100644
--- a/src-tauri/src/services/skill.rs
+++ b/src-tauri/src/services/skill.rs
@@ -529,6 +529,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"));
+ }
+ }
}
// 默认路径:回退到用户主目录下的标准位置
@@ -544,6 +549,7 @@ impl SkillService {
AppType::Gemini => home.join(".gemini").join("skills"),
AppType::OpenCode => home.join(".config").join("opencode").join("skills"),
AppType::OpenClaw => home.join(".openclaw").join("skills"),
+ AppType::Hermes => home.join(".hermes").join("skills"),
})
}
diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs
index de1e4cc236..3c27ec990e 100644
--- a/src-tauri/src/services/stream_check.rs
+++ b/src-tauri/src/services/stream_check.rs
@@ -268,9 +268,9 @@ impl StreamCheckService {
)
.await
}
- AppType::OpenCode | AppType::OpenClaw => {
+ AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// Already handled via early dispatch above
- unreachable!("OpenCode/OpenClaw 已通过 check_once_without_adapter 处理")
+ unreachable!("OpenCode/OpenClaw/Hermes 已通过 check_once_without_adapter 处理")
}
};
@@ -667,7 +667,17 @@ impl StreamCheckService {
)
.await
}
- _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
+ AppType::Hermes => {
+ Self::check_hermes_stream(
+ &client,
+ provider,
+ &model_to_test,
+ test_prompt,
+ request_timeout,
+ )
+ .await
+ }
+ _ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw/Hermes"),
};
let response_time = start.elapsed().as_millis() as u64;
@@ -1040,6 +1050,88 @@ impl StreamCheckService {
}
}
+ /// Hermes 流式检查
+ ///
+ /// Hermes 使用 snake_case 配置格式:
+ /// - `base_url`: API 端点
+ /// - `api_key`: API 密钥
+ /// - `transport`: 传输类型(默认 "openai_chat")
+ ///
+ /// 目前仅支持 `openai_chat` 传输类型,使用 OpenAI Chat Completions API 格式。
+ async fn check_hermes_stream(
+ client: &Client,
+ provider: &Provider,
+ model: &str,
+ test_prompt: &str,
+ timeout: std::time::Duration,
+ ) -> Result<(u16, String), AppError> {
+ let base_url = Self::extract_hermes_base_url(provider)?;
+ let api_key = Self::extract_hermes_api_key(provider)?;
+ let transport = Self::extract_hermes_transport(provider);
+
+ match transport.as_deref() {
+ Some("openai_chat") | None => {
+ // 默认使用 OpenAI Chat Completions API 格式
+ let auth = AuthInfo::new(api_key, AuthStrategy::Bearer);
+ Self::check_claude_stream(
+ client,
+ &base_url,
+ &auth,
+ model,
+ test_prompt,
+ timeout,
+ provider,
+ Some("openai_chat"),
+ None,
+ )
+ .await
+ }
+ Some(other) => Err(AppError::localized(
+ "hermes_transport_not_supported",
+ format!("Hermes 暂不支持传输类型: {other}"),
+ format!("Hermes transport type not supported: {other}"),
+ )),
+ }
+ }
+
+ fn extract_hermes_base_url(provider: &Provider) -> Result {
+ provider
+ .settings_config
+ .get("base_url")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .ok_or_else(|| {
+ AppError::localized(
+ "hermes_base_url_missing",
+ "Hermes 供应商缺少 base_url 字段",
+ "Hermes provider is missing the `base_url` field",
+ )
+ })
+ }
+
+ fn extract_hermes_api_key(provider: &Provider) -> Result {
+ provider
+ .settings_config
+ .get("api_key")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ .ok_or_else(|| {
+ AppError::localized(
+ "hermes_api_key_missing",
+ "Hermes 供应商缺少 api_key 字段",
+ "Hermes provider is missing the `api_key` field",
+ )
+ })
+ }
+
+ fn extract_hermes_transport(provider: &Provider) -> Option {
+ provider
+ .settings_config
+ .get("transport")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ }
+
/// 按 OpenCode 的实际 SDK 包特性确定 baseURL:
/// - 用户显式填写的 `options.baseURL` 总是优先
/// - 否则根据 `npm` 返回 AI SDK 包自带的默认端点
@@ -1213,9 +1305,23 @@ impl StreamCheckService {
// Try to extract first model from the models array
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
+ AppType::Hermes => {
+ // Hermes uses model field directly in settings_config (snake_case)
+ Self::extract_hermes_model(provider)
+ .unwrap_or_else(|| "claude-sonnet-4-6".to_string())
+ }
}
}
+ fn extract_hermes_model(provider: &Provider) -> Option {
+ // Hermes settings_config: { name, base_url, api_key, model, transport }
+ provider
+ .settings_config
+ .get("model")
+ .and_then(|m| m.as_str())
+ .map(|s| s.to_string())
+ }
+
fn extract_opencode_model(provider: &Provider) -> Option {
let models = provider
.settings_config
diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs
index 8b41ba5179..aa87c9d0cc 100644
--- a/src-tauri/src/settings.rs
+++ b/src-tauri/src/settings.rs
@@ -36,6 +36,8 @@ pub struct VisibleApps {
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
+ #[serde(default = "default_true")]
+ pub hermes: bool,
}
impl Default for VisibleApps {
@@ -46,6 +48,7 @@ impl Default for VisibleApps {
gemini: true,
opencode: true,
openclaw: true,
+ hermes: true,
}
}
}
@@ -59,6 +62,7 @@ impl VisibleApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => self.openclaw,
+ AppType::Hermes => self.hermes,
}
}
}
@@ -231,6 +235,8 @@ pub struct AppSettings {
pub opencode_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 hermes_config_dir: Option,
// ===== 当前供应商 ID(设备级)=====
/// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current)
@@ -248,6 +254,9 @@ pub struct AppSettings {
/// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_openclaw: Option,
+ /// 当前 Hermes 供应商 ID(本地存储,优先于数据库 is_current)
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub current_provider_hermes: Option,
// ===== Skill 同步设置 =====
/// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy
@@ -315,11 +324,13 @@ impl Default for AppSettings {
gemini_config_dir: None,
opencode_config_dir: None,
openclaw_config_dir: None,
+ hermes_config_dir: None,
current_provider_claude: None,
current_provider_codex: None,
current_provider_gemini: None,
current_provider_opencode: None,
current_provider_openclaw: None,
+ current_provider_hermes: None,
skill_sync_method: SyncMethod::default(),
skill_storage_location: SkillStorageLocation::default(),
webdav_sync: None,
@@ -377,6 +388,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.language = self
.language
.as_ref()
@@ -577,6 +595,14 @@ pub fn get_openclaw_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))
+}
+
// ===== 当前供应商管理函数 =====
/// 获取指定应用类型的当前供应商 ID(从本地 settings 读取)
@@ -591,6 +617,7 @@ pub fn get_current_provider(app_type: &AppType) -> Option {
AppType::Gemini => settings.current_provider_gemini.clone(),
AppType::OpenCode => settings.current_provider_opencode.clone(),
AppType::OpenClaw => settings.current_provider_openclaw.clone(),
+ AppType::Hermes => settings.current_provider_hermes.clone(),
}
}
@@ -606,6 +633,7 @@ pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(),
AppType::Gemini => settings.current_provider_gemini = id_owned.clone(),
AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(),
AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(),
+ AppType::Hermes => settings.current_provider_hermes = id_owned.clone(),
})
}
diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs
index 24a1667fbb..2be08faff0 100644
--- a/src-tauri/tests/import_export_sync.rs
+++ b/src-tauri/tests/import_export_sync.rs
@@ -554,6 +554,7 @@ command = "echo"
codex: false, // 初始未启用
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -682,6 +683,7 @@ fn import_from_claude_merges_into_config() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs
index 1ed24a99d1..006b036748 100644
--- a/src-tauri/tests/mcp_commands.rs
+++ b/src-tauri/tests/mcp_commands.rs
@@ -217,6 +217,7 @@ fn set_mcp_enabled_for_codex_writes_live_config() {
codex: false, // 初始未启用
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -281,6 +282,7 @@ fn enabling_codex_mcp_skips_when_codex_dir_missing() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -325,6 +327,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -358,6 +361,7 @@ fn upsert_mcp_server_disabling_app_removes_from_claude_live_config() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -490,6 +494,7 @@ fn enabling_gemini_mcp_skips_when_gemini_dir_missing() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -544,6 +549,7 @@ fn enabling_claude_mcp_skips_when_claude_config_absent() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -604,6 +610,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -625,6 +632,7 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
@@ -655,3 +663,92 @@ fn sync_all_enabled_removes_known_disabled_but_preserves_unknown_live_entries()
"live entries unknown to DB should be preserved"
);
}
+
+#[test]
+fn import_default_config_hermes_reads_from_custom_providers() {
+ use cc_switch_lib::get_hermes_config_path;
+
+ let _guard = test_mutex().lock().expect("acquire test mutex");
+ reset_test_fs();
+ let home = ensure_test_home();
+
+ // 创建 Hermes 配置目录和文件
+ let hermes_dir = home.join(".hermes");
+ fs::create_dir_all(&hermes_dir).expect("create hermes dir");
+
+ let config_path = get_hermes_config_path();
+ let config_yaml = r#"
+model:
+ default: claude-sonnet-4-6
+ provider: "My Custom Provider"
+
+custom_providers:
+ - name: "My Custom Provider"
+ base_url: "https://api.custom.com/v1"
+ api_key: "sk-custom-key"
+ model: "claude-sonnet-4-6"
+ transport: "openai_chat"
+
+ - name: "Another Provider"
+ base_url: "https://api.another.com/v1"
+ api_key: "sk-another-key"
+ model: "claude-opus-4"
+ transport: "openai_chat"
+"#;
+ fs::write(&config_path, config_yaml).expect("write hermes config.yaml");
+
+ let mut config = MultiAppConfig::default();
+ config.ensure_app(&AppType::Hermes);
+ let state = create_test_state_with_config(&config).expect("create test state");
+
+ import_default_config_test_hook(&state, AppType::Hermes)
+ .expect("import default config succeeds");
+
+ // 验证导入的 provider
+ let providers = state
+ .db
+ .get_all_providers(AppType::Hermes.as_str())
+ .expect("get all providers");
+
+ // 只应该导入当前激活的 provider(由 model.provider 指定)
+ let current_id = state
+ .db
+ .get_current_provider(AppType::Hermes.as_str())
+ .expect("get current provider");
+ assert_eq!(current_id.as_deref(), Some("default"));
+
+ let default_provider = providers.get("default").expect("default provider");
+
+ // 验证 provider name 是从 custom_providers 中读取的
+ assert_eq!(default_provider.name, "My Custom Provider");
+
+ // 验证 settings_config 包含正确的字段
+ assert_eq!(
+ default_provider
+ .settings_config
+ .get("name")
+ .and_then(|v| v.as_str()),
+ Some("My Custom Provider")
+ );
+ assert_eq!(
+ default_provider
+ .settings_config
+ .get("base_url")
+ .and_then(|v| v.as_str()),
+ Some("https://api.custom.com/v1")
+ );
+ assert_eq!(
+ default_provider
+ .settings_config
+ .get("api_key")
+ .and_then(|v| v.as_str()),
+ Some("sk-custom-key")
+ );
+ assert_eq!(
+ default_provider
+ .settings_config
+ .get("model")
+ .and_then(|v| v.as_str()),
+ Some("claude-sonnet-4-6")
+ );
+}
diff --git a/src-tauri/tests/provider_commands.rs b/src-tauri/tests/provider_commands.rs
index 3288489266..71fcc48d91 100644
--- a/src-tauri/tests/provider_commands.rs
+++ b/src-tauri/tests/provider_commands.rs
@@ -75,6 +75,7 @@ command = "say"
codex: true, // 启用 Codex
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
diff --git a/src-tauri/tests/provider_service.rs b/src-tauri/tests/provider_service.rs
index 2b9eddaa96..d9679f921f 100644
--- a/src-tauri/tests/provider_service.rs
+++ b/src-tauri/tests/provider_service.rs
@@ -163,6 +163,7 @@ command = "say"
codex: true,
gemini: false,
opencode: false,
+ hermes: false,
},
description: None,
homepage: None,
diff --git a/src-tauri/tests/skill_sync.rs b/src-tauri/tests/skill_sync.rs
index b1e0c3a61a..9334bb0aa7 100644
--- a/src-tauri/tests/skill_sync.rs
+++ b/src-tauri/tests/skill_sync.rs
@@ -57,6 +57,7 @@ fn import_from_apps_respects_explicit_app_selection() {
codex: false,
gemini: false,
opencode: true,
+ hermes: false,
},
}],
)
@@ -108,6 +109,7 @@ fn sync_to_app_removes_disabled_and_orphaned_ssot_symlinks() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
installed_at: 0,
content_hash: None,
@@ -154,6 +156,7 @@ fn uninstall_skill_creates_backup_before_removing_ssot() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
installed_at: 123,
content_hash: None,
@@ -224,6 +227,7 @@ fn restore_skill_backup_restores_files_to_ssot_and_current_app() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
installed_at: 456,
content_hash: None,
@@ -307,6 +311,7 @@ fn delete_skill_backup_removes_backup_directory() {
codex: false,
gemini: false,
opencode: false,
+ hermes: false,
},
installed_at: 789,
content_hash: None,
diff --git a/src/App.tsx b/src/App.tsx
index 4406ac7538..e22b419cbf 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -172,6 +172,7 @@ function App() {
claude: true,
codex: true,
gemini: true,
+ hermes: true,
opencode: true,
openclaw: true,
};
@@ -182,6 +183,7 @@ function App() {
if (visibleApps.gemini) return "gemini";
if (visibleApps.opencode) return "opencode";
if (visibleApps.openclaw) return "openclaw";
+ if (visibleApps.hermes) return "hermes";
return "claude"; // fallback
};
diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx
index 61d780e90a..4fe2004d95 100644
--- a/src/components/AppSwitcher.tsx
+++ b/src/components/AppSwitcher.tsx
@@ -10,7 +10,15 @@ interface AppSwitcherProps {
compact?: boolean;
}
-const ALL_APPS: AppId[] = ["claude", "codex", "gemini", "opencode", "openclaw"];
+const ALL_APPS: AppId[] = [
+ "claude",
+ "codex",
+ "gemini",
+ "opencode",
+ "openclaw",
+ "hermes",
+];
+
const STORAGE_KEY = "cc-switch-last-app";
export function AppSwitcher({
@@ -29,6 +37,7 @@ export function AppSwitcher({
claude: "claude",
codex: "openai",
gemini: "gemini",
+ hermes: "terminal",
opencode: "opencode",
openclaw: "openclaw",
};
@@ -36,6 +45,7 @@ export function AppSwitcher({
claude: "Claude",
codex: "Codex",
gemini: "Gemini",
+ hermes: "Hermes",
opencode: "OpenCode",
openclaw: "OpenClaw",
};
diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx
index 70ab3b03a0..8e4ddb2897 100644
--- a/src/components/mcp/McpFormModal.tsx
+++ b/src/components/mcp/McpFormModal.tsx
@@ -7,7 +7,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import JsonEditor from "@/components/JsonEditor";
import type { AppId } from "@/lib/api/types";
-import { McpServer, McpServerSpec } from "@/types";
+import { McpApps, McpServer, McpServerSpec } from "@/types";
import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets";
import McpWizardModal from "./McpWizardModal";
import {
@@ -61,13 +61,7 @@ const McpFormModal: React.FC = ({
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
- const [enabledApps, setEnabledApps] = useState<{
- claude: boolean;
- codex: boolean;
- gemini: boolean;
- opencode: boolean;
- openclaw: boolean;
- }>(() => {
+ const [enabledApps, setEnabledApps] = useState(() => {
if (initialData?.apps) {
return { ...initialData.apps };
}
@@ -77,6 +71,7 @@ const McpFormModal: React.FC = ({
gemini: defaultEnabledApps.includes("gemini"),
opencode: defaultEnabledApps.includes("opencode"),
openclaw: defaultEnabledApps.includes("openclaw"),
+ hermes: defaultEnabledApps.includes("hermes"),
};
});
diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx
index 761ddcd651..daed5698c4 100644
--- a/src/components/mcp/UnifiedMcpPanel.tsx
+++ b/src/components/mcp/UnifiedMcpPanel.tsx
@@ -56,7 +56,14 @@ const UnifiedMcpPanel = React.forwardRef<
}, [serversMap]);
const enabledCounts = useMemo(() => {
- const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
+ const counts = {
+ claude: 0,
+ codex: 0,
+ gemini: 0,
+ opencode: 0,
+ openclaw: 0,
+ hermes: 0,
+ };
serverEntries.forEach(([_, server]) => {
for (const app of MCP_SKILLS_APP_IDS) {
if (server.apps[app]) counts[app]++;
diff --git a/src/components/prompts/PromptFormModal.tsx b/src/components/prompts/PromptFormModal.tsx
index 84e18d2551..0a9c86f942 100644
--- a/src/components/prompts/PromptFormModal.tsx
+++ b/src/components/prompts/PromptFormModal.tsx
@@ -35,6 +35,7 @@ const PromptFormModal: React.FC = ({
codex: "AGENTS.md",
gemini: "GEMINI.md",
opencode: "AGENTS.md",
+ hermes: "AGENTS.md",
};
const filename = filenameMap[appId as Exclude];
const [name, setName] = useState("");
diff --git a/src/components/prompts/PromptFormPanel.tsx b/src/components/prompts/PromptFormPanel.tsx
index c4481fa4cf..d946cd3093 100644
--- a/src/components/prompts/PromptFormPanel.tsx
+++ b/src/components/prompts/PromptFormPanel.tsx
@@ -28,6 +28,7 @@ const PromptFormPanel: React.FC = ({
claude: "CLAUDE.md",
codex: "AGENTS.md",
gemini: "GEMINI.md",
+ hermes: "AGENTS.md",
opencode: "AGENTS.md",
openclaw: "AGENTS.md",
};
diff --git a/src/components/providers/forms/EndpointSpeedTest.tsx b/src/components/providers/forms/EndpointSpeedTest.tsx
index 2143d21dbb..27913254b9 100644
--- a/src/components/providers/forms/EndpointSpeedTest.tsx
+++ b/src/components/providers/forms/EndpointSpeedTest.tsx
@@ -15,6 +15,7 @@ const ENDPOINT_TIMEOUT_SECS: Record = {
gemini: 8,
opencode: 8,
openclaw: 8,
+ hermes: 8,
};
interface TestResult {
diff --git a/src/components/providers/forms/HermesFormFields.tsx b/src/components/providers/forms/HermesFormFields.tsx
new file mode 100644
index 0000000000..16ff99cd0c
--- /dev/null
+++ b/src/components/providers/forms/HermesFormFields.tsx
@@ -0,0 +1,211 @@
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { FormLabel } from "@/components/ui/form";
+import { Download, Loader2, Info } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { toast } from "sonner";
+import EndpointSpeedTest from "./EndpointSpeedTest";
+import { ApiKeySection, EndpointField, ModelInputWithFetch } from "./shared";
+import {
+ fetchModelsForConfig,
+ showFetchModelsError,
+ type FetchedModel,
+} from "@/lib/api/model-fetch";
+import type { ProviderCategory } from "@/types";
+
+interface EndpointCandidate {
+ url: string;
+}
+
+interface HermesFormFieldsProps {
+ providerId?: string;
+ // API Key
+ apiKey: string;
+ onApiKeyChange: (key: string) => void;
+ category?: ProviderCategory;
+ shouldShowApiKeyLink: boolean;
+ websiteUrl: string;
+ isPartner?: boolean;
+ partnerPromotionKey?: string;
+
+ // Base URL
+ shouldShowSpeedTest: boolean;
+ baseUrl: string;
+ onBaseUrlChange: (url: string) => void;
+ isEndpointModalOpen: boolean;
+ onEndpointModalToggle: (open: boolean) => void;
+ onCustomEndpointsChange: (endpoints: string[]) => void;
+ autoSelect: boolean;
+ onAutoSelectChange: (checked: boolean) => void;
+
+ // Model
+ shouldShowModelField: boolean;
+ model: string;
+ onModelChange: (value: string) => void;
+
+ // Speed Test Endpoints
+ speedTestEndpoints: EndpointCandidate[];
+}
+
+export function HermesFormFields({
+ providerId,
+ apiKey,
+ onApiKeyChange,
+ category,
+ shouldShowApiKeyLink,
+ websiteUrl,
+ isPartner,
+ partnerPromotionKey,
+ shouldShowSpeedTest,
+ baseUrl,
+ onBaseUrlChange,
+ isEndpointModalOpen,
+ onEndpointModalToggle,
+ onCustomEndpointsChange,
+ autoSelect,
+ onAutoSelectChange,
+ shouldShowModelField,
+ model,
+ onModelChange,
+ speedTestEndpoints,
+}: HermesFormFieldsProps) {
+ const { t } = useTranslation();
+
+ const [fetchedModels, setFetchedModels] = useState([]);
+ const [isFetchingModels, setIsFetchingModels] = useState(false);
+
+ const handleFetchModels = useCallback(() => {
+ if (!baseUrl || !apiKey) {
+ showFetchModelsError(null, t, {
+ hasApiKey: !!apiKey,
+ hasBaseUrl: !!baseUrl,
+ });
+ return;
+ }
+ setIsFetchingModels(true);
+ fetchModelsForConfig(baseUrl, apiKey)
+ .then((models) => {
+ setFetchedModels(models);
+ if (models.length === 0) {
+ toast.info(t("providerForm.fetchModelsEmpty"));
+ } else {
+ toast.success(
+ t("providerForm.fetchModelsSuccess", { count: models.length }),
+ );
+ }
+ })
+ .catch((err) => {
+ console.warn("[ModelFetch] Failed:", err);
+ showFetchModelsError(err, t);
+ })
+ .finally(() => setIsFetchingModels(false));
+ }, [baseUrl, apiKey, t]);
+
+ // Check if this is official provider (no API key needed)
+ const isOfficial = category === "official";
+
+ return (
+ <>
+ {/* Official Provider hint */}
+ {isOfficial && (
+
+
+
+
+
+ {t("provider.form.hermes.officialTitle", {
+ defaultValue: "Official Provider",
+ })}
+
+
+ {t("provider.form.hermes.officialHint", {
+ defaultValue:
+ "Official provider uses default settings. No API key required.",
+ })}
+
+
+
+
+ )}
+
+ {/* API Key input - always show for non-official providers */}
+ {!isOfficial && (
+
+ )}
+
+ {/* Base URL input */}
+ {shouldShowSpeedTest && (
+ onEndpointModalToggle(true)}
+ />
+ )}
+
+ {/* Model input */}
+ {shouldShowModelField && (
+
+
+
+ {t("provider.form.hermes.model", { defaultValue: "Model" })}
+
+
+
+
+
+ )}
+
+ {/* Endpoint speed test modal */}
+ {shouldShowSpeedTest && isEndpointModalOpen && (
+ onEndpointModalToggle(false)}
+ autoSelect={autoSelect}
+ onAutoSelectChange={onAutoSelectChange}
+ onCustomEndpointsChange={onCustomEndpointsChange}
+ />
+ )}
+ >
+ );
+}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx
index 7f8cf560e7..b11374cd3e 100644
--- a/src/components/providers/forms/ProviderForm.tsx
+++ b/src/components/providers/forms/ProviderForm.tsx
@@ -37,6 +37,10 @@ import {
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
+import {
+ hermesProviderPresets,
+ type HermesProviderPreset,
+} from "@/config/hermesProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
@@ -56,6 +60,7 @@ import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
+import { HermesFormFields } from "./HermesFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { parseOmoOtherFieldsObject } from "@/types/omo";
import {
@@ -75,6 +80,7 @@ import {
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
+ useHermesConfigState,
useGeminiCommonConfig,
useOmoModelSource,
useOpencodeFormState,
@@ -91,6 +97,7 @@ import {
GEMINI_DEFAULT_CONFIG,
OPENCODE_DEFAULT_CONFIG,
OPENCLAW_DEFAULT_CONFIG,
+ HERMES_DEFAULT_CONFIG,
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { resolveManagedAccountId } from "@/lib/authBinding";
@@ -103,7 +110,8 @@ type PresetEntry = {
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset
- | OpenClawProviderPreset;
+ | OpenClawProviderPreset
+ | HermesProviderPreset;
};
interface ProviderFormProps {
@@ -253,7 +261,9 @@ export function ProviderForm({
? OPENCODE_DEFAULT_CONFIG
: appId === "openclaw"
? OPENCLAW_DEFAULT_CONFIG
- : CLAUDE_DEFAULT_CONFIG,
+ : appId === "hermes"
+ ? HERMES_DEFAULT_CONFIG
+ : CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
@@ -450,6 +460,11 @@ export function ProviderForm({
id: `openclaw-${index}`,
preset,
}));
+ } else if (appId === "hermes") {
+ return hermesProviderPresets.map((preset, index) => ({
+ id: `hermes-${index}`,
+ preset,
+ }));
}
return providerPresets
.filter((p) => !p.hidden)
@@ -594,6 +609,23 @@ export function ProviderForm({
selectedPresetId: selectedPresetId ?? undefined,
});
+ // ── Extracted hooks: Hermes ─────────────────────
+
+ const {
+ hermesApiKey,
+ hermesBaseUrl,
+ hermesModel,
+ handleHermesApiKeyChange,
+ handleHermesBaseUrlChange,
+ handleHermesModelChange,
+ resetHermesConfig,
+ buildHermesSettingsConfig,
+ } = useHermesConfigState({
+ initialData: appId === "hermes" ? initialData : undefined,
+ onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
+ getSettingsConfig: () => form.getValues("settingsConfig"),
+ });
+
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
const {
@@ -906,6 +938,9 @@ export function ProviderForm({
} catch (err) {
settingsConfig = values.settingsConfig.trim();
}
+ } else if (appId === "hermes") {
+ const hermesConfig = buildHermesSettingsConfig();
+ settingsConfig = JSON.stringify(hermesConfig);
} else if (
appId === "opencode" &&
(category === "omo" || category === "omo-slim")
@@ -1182,6 +1217,20 @@ export function ProviderForm({
formWebsiteUrl: form.watch("websiteUrl") || "",
});
+ // 使用 API Key 链接 hook (Hermes)
+ const {
+ shouldShowApiKeyLink: shouldShowHermesApiKeyLink,
+ websiteUrl: hermesWebsiteUrl,
+ isPartner: isHermesPartner,
+ partnerPromotionKey: hermesPartnerPromotionKey,
+ } = useApiKeyLink({
+ appId: "hermes",
+ category,
+ selectedPresetId,
+ presetEntries,
+ formWebsiteUrl: form.watch("websiteUrl") || "",
+ });
+
// 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
@@ -1213,6 +1262,10 @@ export function ProviderForm({
if (appId === "openclaw") {
openclawForm.resetOpenclawState();
}
+ // Hermes 自定义模式:重置为空配置
+ if (appId === "hermes") {
+ resetHermesConfig({});
+ }
return;
}
@@ -1262,6 +1315,22 @@ export function ProviderForm({
return;
}
+ if (appId === "hermes") {
+ const preset = entry.preset as ProviderPreset;
+ const config = preset.settingsConfig as Record;
+
+ resetHermesConfig(config);
+
+ form.reset({
+ name: preset.nameKey ? t(preset.nameKey) : preset.name,
+ websiteUrl: preset.websiteUrl ?? "",
+ settingsConfig: JSON.stringify(config, null, 2),
+ icon: preset.icon ?? "",
+ iconColor: preset.iconColor ?? "",
+ });
+ return;
+ }
+
if (appId === "opencode") {
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
@@ -1640,6 +1709,31 @@ export function ProviderForm({
/>
)}
+ {appId === "hermes" && (
+
+ )}
+
{appId === "opencode" && !isAnyOmoCategory && (
;
+ };
+ onSettingsConfigChange?: (config: string) => void;
+ getSettingsConfig?: () => string;
+}
+
+/**
+ * 管理 Hermes 配置状态
+ * Hermes 配置结构: { name, base_url, api_key, model, transport }
+ * 使用蛇形命名以匹配后端 Hermes config.yaml 格式
+ */
+export function useHermesConfigState({
+ initialData,
+ onSettingsConfigChange,
+ getSettingsConfig,
+}: UseHermesConfigStateProps) {
+ const [hermesApiKey, setHermesApiKey] = useState("");
+ const [hermesBaseUrl, setHermesBaseUrl] = useState("");
+ const [hermesModel, setHermesModel] = useState("");
+ const [hermesProviderName, setHermesProviderName] = useState("");
+
+ // 构建 settingsConfig JSON(使用蛇形命名)
+ const buildSettingsConfig = useCallback(() => {
+ const config: Record = {
+ transport: "openai_chat",
+ };
+
+ if (hermesProviderName) {
+ config.name = hermesProviderName;
+ }
+ if (hermesBaseUrl) {
+ config.base_url = hermesBaseUrl;
+ }
+ if (hermesApiKey) {
+ config.api_key = hermesApiKey;
+ }
+ if (hermesModel) {
+ config.model = hermesModel;
+ }
+
+ return config;
+ }, [hermesProviderName, hermesBaseUrl, hermesApiKey, hermesModel]);
+
+ // 更新 settingsConfig(用于实时同步)
+ const updateHermesConfig = useCallback(
+ (updater: (config: Record) => void) => {
+ if (!onSettingsConfigChange || !getSettingsConfig) return;
+
+ try {
+ const configStr = getSettingsConfig();
+ const config = configStr ? JSON.parse(configStr) : {};
+ updater(config);
+ onSettingsConfigChange(JSON.stringify(config, null, 2));
+ } catch {
+ // 如果解析失败,从当前状态重新构建
+ const config = buildSettingsConfig();
+ updater(config);
+ onSettingsConfigChange(JSON.stringify(config, null, 2));
+ }
+ },
+ [onSettingsConfigChange, getSettingsConfig, buildSettingsConfig],
+ );
+
+ // 初始化 Hermes 配置(编辑模式)
+ useEffect(() => {
+ if (!initialData) return;
+
+ const config = initialData.settingsConfig;
+ if (typeof config === "object" && config !== null) {
+ const cfg = config as Record;
+
+ // 使用蛇形命名读取
+ if (typeof cfg.api_key === "string") {
+ setHermesApiKey(cfg.api_key);
+ }
+ if (typeof cfg.base_url === "string") {
+ setHermesBaseUrl(cfg.base_url);
+ }
+ if (typeof cfg.model === "string") {
+ setHermesModel(cfg.model);
+ }
+ if (typeof cfg.name === "string") {
+ setHermesProviderName(cfg.name);
+ }
+ // 兼容驼峰命名(如果之前使用了驼峰)
+ if (typeof cfg.apiKey === "string" && !cfg.api_key) {
+ setHermesApiKey(cfg.apiKey as string);
+ }
+ if (typeof cfg.baseUrl === "string" && !cfg.base_url) {
+ setHermesBaseUrl(cfg.baseUrl as string);
+ }
+ if (typeof cfg.provider_name === "string" && !cfg.name) {
+ setHermesProviderName(cfg.provider_name as string);
+ }
+ }
+ }, [initialData]);
+
+ // 处理 Hermes API Key 输入
+ const handleHermesApiKeyChange = useCallback(
+ (key: string) => {
+ const trimmed = key.trim();
+ setHermesApiKey(trimmed);
+ updateHermesConfig((config) => {
+ config.api_key = trimmed;
+ });
+ },
+ [updateHermesConfig],
+ );
+
+ // 处理 Hermes Base URL 变化
+ const handleHermesBaseUrlChange = useCallback(
+ (url: string) => {
+ const sanitized = url.trim().replace(/\/+$/, "");
+ setHermesBaseUrl(sanitized);
+ updateHermesConfig((config) => {
+ config.base_url = sanitized;
+ });
+ },
+ [updateHermesConfig],
+ );
+
+ // 处理 Hermes Model 变化
+ const handleHermesModelChange = useCallback(
+ (model: string) => {
+ const trimmed = model.trim();
+ setHermesModel(trimmed);
+ updateHermesConfig((config) => {
+ config.model = trimmed;
+ });
+ },
+ [updateHermesConfig],
+ );
+
+ // 处理 Hermes Provider Name 变化
+ const handleHermesProviderNameChange = useCallback(
+ (name: string) => {
+ const trimmed = name.trim();
+ setHermesProviderName(trimmed);
+ updateHermesConfig((config) => {
+ config.name = trimmed;
+ });
+ },
+ [updateHermesConfig],
+ );
+
+ // 重置配置(用于预设切换)
+ const resetHermesConfig = useCallback(
+ (settingsConfig: Record) => {
+ const cfg = settingsConfig as Record;
+
+ // 使用蛇形命名
+ if (typeof cfg.api_key === "string") {
+ setHermesApiKey(cfg.api_key);
+ } else if (typeof cfg.apiKey === "string") {
+ setHermesApiKey(cfg.apiKey as string);
+ } else {
+ setHermesApiKey("");
+ }
+
+ if (typeof cfg.base_url === "string") {
+ setHermesBaseUrl(cfg.base_url);
+ } else if (typeof cfg.baseUrl === "string") {
+ setHermesBaseUrl(cfg.baseUrl as string);
+ } else {
+ setHermesBaseUrl("");
+ }
+
+ if (typeof cfg.model === "string") {
+ setHermesModel(cfg.model);
+ } else {
+ setHermesModel("");
+ }
+
+ if (typeof cfg.name === "string") {
+ setHermesProviderName(cfg.name);
+ } else if (typeof cfg.provider_name === "string") {
+ setHermesProviderName(cfg.provider_name as string);
+ } else {
+ setHermesProviderName("");
+ }
+ },
+ [],
+ );
+
+ return {
+ hermesApiKey,
+ hermesBaseUrl,
+ hermesModel,
+ hermesProviderName,
+ setHermesApiKey,
+ setHermesBaseUrl,
+ setHermesModel,
+ setHermesProviderName,
+ handleHermesApiKeyChange,
+ handleHermesBaseUrlChange,
+ handleHermesModelChange,
+ handleHermesProviderNameChange,
+ resetHermesConfig,
+ buildHermesSettingsConfig: buildSettingsConfig,
+ };
+}
diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx
index 1052f2ece1..2fc8e3bd75 100644
--- a/src/components/proxy/ProxyPanel.tsx
+++ b/src/components/proxy/ProxyPanel.tsx
@@ -252,29 +252,31 @@ export function ProxyPanel({
})}
- {(["claude", "codex", "gemini"] as const).map((appType) => {
- const isEnabled =
- takeoverStatus?.[
- appType as keyof typeof takeoverStatus
- ] ?? false;
- return (
-
-
- {appType}
-
-
- handleTakeoverChange(appType, checked)
- }
- disabled={setTakeoverForApp.isPending}
- />
-
- );
- })}
+ {(["claude", "codex", "gemini", "hermes"] as const).map(
+ (appType) => {
+ const isEnabled =
+ takeoverStatus?.[
+ appType as keyof typeof takeoverStatus
+ ] ?? false;
+ return (
+
+
+ {appType}
+
+
+ handleTakeoverChange(appType, checked)
+ }
+ disabled={setTakeoverForApp.isPending}
+ />
+
+ );
+ },
+ )}
{t("proxy.takeover.hint", {
diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx
index 0c30472ce1..a6e4e63cc8 100644
--- a/src/components/settings/AppVisibilitySettings.tsx
+++ b/src/components/settings/AppVisibilitySettings.tsx
@@ -21,6 +21,7 @@ const APP_CONFIG: Array<{
{ id: "gemini", icon: "gemini", nameKey: "apps.gemini" },
{ id: "opencode", icon: "opencode", nameKey: "apps.opencode" },
{ id: "openclaw", icon: "openclaw", nameKey: "apps.openclaw" },
+ { id: "hermes", icon: "terminal", nameKey: "apps.hermes" },
];
export function AppVisibilitySettings({
@@ -33,6 +34,7 @@ export function AppVisibilitySettings({
claude: true,
codex: true,
gemini: true,
+ hermes: true,
opencode: true,
openclaw: true,
};
diff --git a/src/components/settings/DirectorySettings.tsx b/src/components/settings/DirectorySettings.tsx
index c633782cbb..3e3b310d5e 100644
--- a/src/components/settings/DirectorySettings.tsx
+++ b/src/components/settings/DirectorySettings.tsx
@@ -16,6 +16,7 @@ interface DirectorySettingsProps {
codexDir?: string;
geminiDir?: string;
opencodeDir?: string;
+ hermesDir?: string;
onDirectoryChange: (app: AppId, value?: string) => void;
onBrowseDirectory: (app: AppId) => Promise;
onResetDirectory: (app: AppId) => Promise;
@@ -31,6 +32,7 @@ export function DirectorySettings({
codexDir,
geminiDir,
opencodeDir,
+ hermesDir: _hermesDir,
onDirectoryChange,
onBrowseDirectory,
onResetDirectory,
diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx
index 701af49292..e2962ab048 100644
--- a/src/components/skills/UnifiedSkillsPanel.tsx
+++ b/src/components/skills/UnifiedSkillsPanel.tsx
@@ -113,7 +113,14 @@ const UnifiedSkillsPanel = React.forwardRef<
}, [skillUpdates]);
const enabledCounts = useMemo(() => {
- const counts = { claude: 0, codex: 0, gemini: 0, opencode: 0, openclaw: 0 };
+ const counts = {
+ claude: 0,
+ codex: 0,
+ gemini: 0,
+ opencode: 0,
+ openclaw: 0,
+ hermes: 0,
+ };
if (!skills) return counts;
skills.forEach((skill) => {
for (const app of MCP_SKILLS_APP_IDS) {
@@ -734,6 +741,7 @@ const ImportSkillsDialog: React.FC = ({
claude: skill.foundIn.includes("claude"),
codex: skill.foundIn.includes("codex"),
gemini: skill.foundIn.includes("gemini"),
+ hermes: skill.foundIn.includes("hermes"),
opencode: skill.foundIn.includes("opencode"),
openclaw: false,
},
@@ -761,6 +769,7 @@ const ImportSkillsDialog: React.FC = ({
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
})),
);
@@ -815,6 +824,7 @@ const ImportSkillsDialog: React.FC = ({
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
}),
[app]: enabled,
},
diff --git a/src/components/universal/UniversalProviderFormModal.tsx b/src/components/universal/UniversalProviderFormModal.tsx
index e085797ba5..bc482a7d08 100644
--- a/src/components/universal/UniversalProviderFormModal.tsx
+++ b/src/components/universal/UniversalProviderFormModal.tsx
@@ -50,6 +50,7 @@ export function UniversalProviderFormModal({
const [claudeEnabled, setClaudeEnabled] = useState(true);
const [codexEnabled, setCodexEnabled] = useState(true);
const [geminiEnabled, setGeminiEnabled] = useState(true);
+ const [hermesEnabled, setHermesEnabled] = useState(true);
// 模型配置
const [models, setModels] = useState({});
@@ -71,6 +72,7 @@ export function UniversalProviderFormModal({
setClaudeEnabled(editingProvider.apps.claude);
setCodexEnabled(editingProvider.apps.codex);
setGeminiEnabled(editingProvider.apps.gemini);
+ setHermesEnabled(editingProvider.apps.hermes);
setModels(editingProvider.models || {});
// 尝试匹配预设
@@ -90,6 +92,7 @@ export function UniversalProviderFormModal({
setClaudeEnabled(defaultPreset.defaultApps.claude);
setCodexEnabled(defaultPreset.defaultApps.codex);
setGeminiEnabled(defaultPreset.defaultApps.gemini);
+ setHermesEnabled(defaultPreset.defaultApps.hermes);
setModels(JSON.parse(JSON.stringify(defaultPreset.defaultModels)));
}
}, [editingProvider, initialPreset, isOpen]);
@@ -103,6 +106,7 @@ export function UniversalProviderFormModal({
setClaudeEnabled(preset.defaultApps.claude);
setCodexEnabled(preset.defaultApps.codex);
setGeminiEnabled(preset.defaultApps.gemini);
+ setHermesEnabled(preset.defaultApps.hermes);
setModels(JSON.parse(JSON.stringify(preset.defaultModels)));
}
},
@@ -200,6 +204,7 @@ requires_openai_auth = true`;
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
+ hermes: hermesEnabled,
},
models,
}
@@ -217,6 +222,7 @@ requires_openai_auth = true`;
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
+ hermes: hermesEnabled,
};
provider.models = models;
provider.websiteUrl = websiteUrl.trim() || undefined;
@@ -259,6 +265,7 @@ requires_openai_auth = true`;
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
+ hermes: hermesEnabled,
},
models,
}
@@ -276,6 +283,7 @@ requires_openai_auth = true`;
claude: claudeEnabled,
codex: codexEnabled,
gemini: geminiEnabled,
+ hermes: hermesEnabled,
};
provider.models = models;
provider.websiteUrl = websiteUrl.trim() || undefined;
@@ -511,6 +519,16 @@ requires_openai_auth = true`;
onCheckedChange={setGeminiEnabled}
/>
+
diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx
index 29169a38fb..935f748b37 100644
--- a/src/components/usage/PricingConfigPanel.tsx
+++ b/src/components/usage/PricingConfigPanel.tsx
@@ -33,7 +33,7 @@ import { Plus, Pencil, Trash2, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { proxyApi } from "@/lib/api/proxy";
-const PRICING_APPS = ["claude", "codex", "gemini"] as const;
+const PRICING_APPS = ["claude", "codex", "gemini", "hermes"] as const;
type PricingApp = (typeof PRICING_APPS)[number];
type PricingModelSource = "request" | "response";
@@ -52,11 +52,12 @@ export function PricingConfigPanel() {
const [isAddingNew, setIsAddingNew] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState(null);
- // 三个应用的配置状态
+ // 应用的配置状态
const [appConfigs, setAppConfigs] = useState({
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
+ hermes: { multiplier: "1", source: "response" },
});
const [originalConfigs, setOriginalConfigs] = useState(
null,
@@ -102,6 +103,7 @@ export function PricingConfigPanel() {
claude: { multiplier: "1", source: "response" },
codex: { multiplier: "1", source: "response" },
gemini: { multiplier: "1", source: "response" },
+ hermes: { multiplier: "1", source: "response" },
};
for (const result of results) {
newState[result.app] = {
diff --git a/src/config/appConfig.tsx b/src/config/appConfig.tsx
index db87b75a84..f3fc031f57 100644
--- a/src/config/appConfig.tsx
+++ b/src/config/appConfig.tsx
@@ -21,6 +21,7 @@ export const APP_IDS: AppId[] = [
"gemini",
"opencode",
"openclaw",
+ "hermes",
];
/** App IDs shown in MCP & Skills panels (excludes OpenClaw) */
@@ -29,6 +30,7 @@ export const MCP_SKILLS_APP_IDS: AppId[] = [
"codex",
"gemini",
"opencode",
+ "hermes",
];
export const APP_ICON_MAP: Record = {
@@ -56,6 +58,21 @@ export const APP_ICON_MAP: Record = {
badgeClass:
"bg-blue-500/10 text-blue-700 dark:text-blue-300 hover:bg-blue-500/20 border-0 gap-1.5",
},
+ hermes: {
+ label: "Hermes",
+ icon: (
+
+ ),
+ activeClass:
+ "bg-emerald-500/10 ring-1 ring-emerald-500/20 hover:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400",
+ badgeClass:
+ "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 border-0 gap-1.5",
+ },
opencode: {
label: "OpenCode",
icon: (
diff --git a/src/config/hermesProviderPresets.ts b/src/config/hermesProviderPresets.ts
new file mode 100644
index 0000000000..71168e61f0
--- /dev/null
+++ b/src/config/hermesProviderPresets.ts
@@ -0,0 +1,47 @@
+/**
+ * Hermes provider presets
+ */
+import type { ProviderPreset } from "./claudeProviderPresets";
+
+export type HermesProviderPreset = ProviderPreset;
+
+export const hermesProviderPresets: HermesProviderPreset[] = [
+ {
+ name: "Anthropic Official",
+ websiteUrl: "https://www.anthropic.com",
+ settingsConfig: {
+ provider: "anthropic",
+ apiKey: "",
+ model: "claude-sonnet-4.6",
+ },
+ isOfficial: true,
+ category: "official",
+ icon: "anthropic",
+ iconColor: "#D4915D",
+ },
+ {
+ name: "OpenRouter",
+ websiteUrl: "https://openrouter.ai",
+ apiKeyUrl: "https://openrouter.ai/keys",
+ settingsConfig: {
+ provider: "openrouter",
+ baseUrl: "https://openrouter.ai/api/v1",
+ apiKey: "",
+ model: "anthropic/claude-sonnet-4.6",
+ },
+ category: "aggregator",
+ icon: "openrouter",
+ iconColor: "#6566F1",
+ },
+ {
+ name: "Custom",
+ websiteUrl: "",
+ settingsConfig: {
+ provider: "",
+ baseUrl: "",
+ apiKey: "",
+ model: "",
+ },
+ category: "custom",
+ },
+];
diff --git a/src/config/universalProviderPresets.ts b/src/config/universalProviderPresets.ts
index 1f829308d8..b1eecb9a97 100644
--- a/src/config/universalProviderPresets.ts
+++ b/src/config/universalProviderPresets.ts
@@ -65,6 +65,7 @@ export const universalProviderPresets: UniversalProviderPreset[] = [
claude: true,
codex: true,
gemini: true,
+ hermes: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
websiteUrl: "https://www.newapi.pro",
@@ -80,6 +81,7 @@ export const universalProviderPresets: UniversalProviderPreset[] = [
claude: true,
codex: true,
gemini: true,
+ hermes: true,
},
defaultModels: NEWAPI_DEFAULT_MODELS,
icon: "openai",
diff --git a/src/hooks/useDirectorySettings.ts b/src/hooks/useDirectorySettings.ts
index 42e63ef9de..19b59c1fbf 100644
--- a/src/hooks/useDirectorySettings.ts
+++ b/src/hooks/useDirectorySettings.ts
@@ -5,7 +5,13 @@ import { homeDir, join } from "@tauri-apps/api/path";
import { settingsApi, type AppId } from "@/lib/api";
import type { SettingsFormState } from "./useSettingsForm";
-type DirectoryKey = "appConfig" | "claude" | "codex" | "gemini" | "opencode";
+type DirectoryKey =
+ | "appConfig"
+ | "claude"
+ | "codex"
+ | "gemini"
+ | "opencode"
+ | "hermes";
export interface ResolvedDirectories {
appConfig: string;
@@ -13,6 +19,7 @@ export interface ResolvedDirectories {
codex: string;
gemini: string;
opencode: string;
+ hermes: string;
}
const sanitizeDir = (value?: string | null): string | undefined => {
@@ -46,7 +53,9 @@ const computeDefaultConfigDir = async (
? ".codex"
: app === "gemini"
? ".gemini"
- : ".config/opencode";
+ : app === "opencode"
+ ? ".config/opencode"
+ : ".hermes";
return await join(home, folder);
} catch (error) {
console.error(
@@ -78,6 +87,7 @@ export interface UseDirectorySettingsResult {
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
+ hermesDir?: string,
) => void;
}
@@ -105,6 +115,7 @@ export function useDirectorySettings({
codex: "",
gemini: "",
opencode: "",
+ hermes: "",
});
const [isLoading, setIsLoading] = useState(true);
@@ -114,6 +125,7 @@ export function useDirectorySettings({
codex: "",
gemini: "",
opencode: "",
+ hermes: "",
});
const initialAppConfigDirRef = useRef(undefined);
@@ -130,22 +142,26 @@ export function useDirectorySettings({
codexDir,
geminiDir,
opencodeDir,
+ hermesDir,
defaultAppConfig,
defaultClaudeDir,
defaultCodexDir,
defaultGeminiDir,
defaultOpencodeDir,
+ defaultHermesDir,
] = await Promise.all([
settingsApi.getAppConfigDirOverride(),
settingsApi.getConfigDir("claude"),
settingsApi.getConfigDir("codex"),
settingsApi.getConfigDir("gemini"),
settingsApi.getConfigDir("opencode"),
+ settingsApi.getConfigDir("hermes"),
computeDefaultAppConfigDir(),
computeDefaultConfigDir("claude"),
computeDefaultConfigDir("codex"),
computeDefaultConfigDir("gemini"),
computeDefaultConfigDir("opencode"),
+ computeDefaultConfigDir("hermes"),
]);
if (!active) return;
@@ -158,6 +174,7 @@ export function useDirectorySettings({
codex: defaultCodexDir ?? "",
gemini: defaultGeminiDir ?? "",
opencode: defaultOpencodeDir ?? "",
+ hermes: defaultHermesDir ?? "",
};
setAppConfigDir(normalizedOverride);
@@ -169,6 +186,7 @@ export function useDirectorySettings({
codex: codexDir || defaultsRef.current.codex,
gemini: geminiDir || defaultsRef.current.gemini,
opencode: opencodeDir || defaultsRef.current.opencode,
+ hermes: hermesDir || defaultsRef.current.hermes,
});
} catch (error) {
console.error(
@@ -201,7 +219,9 @@ export function useDirectorySettings({
? { codexConfigDir: sanitized }
: key === "gemini"
? { geminiConfigDir: sanitized }
- : { opencodeConfigDir: sanitized },
+ : key === "opencode"
+ ? { opencodeConfigDir: sanitized }
+ : { hermesConfigDir: sanitized },
);
}
@@ -229,7 +249,9 @@ export function useDirectorySettings({
? "codex"
: app === "gemini"
? "gemini"
- : "opencode",
+ : app === "opencode"
+ ? "opencode"
+ : "hermes",
value,
);
},
@@ -245,7 +267,9 @@ export function useDirectorySettings({
? "codex"
: app === "gemini"
? "gemini"
- : "opencode";
+ : app === "opencode"
+ ? "opencode"
+ : "hermes";
const currentValue =
key === "claude"
? (settings?.claudeConfigDir ?? resolvedDirs.claude)
@@ -253,7 +277,9 @@ export function useDirectorySettings({
? (settings?.codexConfigDir ?? resolvedDirs.codex)
: key === "gemini"
? (settings?.geminiConfigDir ?? resolvedDirs.gemini)
- : (settings?.opencodeConfigDir ?? resolvedDirs.opencode);
+ : key === "opencode"
+ ? (settings?.opencodeConfigDir ?? resolvedDirs.opencode)
+ : (settings?.hermesConfigDir ?? resolvedDirs.hermes);
try {
const picked = await settingsApi.selectConfigDirectory(currentValue);
@@ -301,7 +327,9 @@ export function useDirectorySettings({
? "codex"
: app === "gemini"
? "gemini"
- : "opencode";
+ : app === "opencode"
+ ? "opencode"
+ : "hermes";
if (!defaultsRef.current[key]) {
const fallback = await computeDefaultConfigDir(app);
if (fallback) {
@@ -335,6 +363,7 @@ export function useDirectorySettings({
codexDir?: string,
geminiDir?: string,
opencodeDir?: string,
+ hermesDir?: string,
) => {
setAppConfigDir(initialAppConfigDirRef.current);
setResolvedDirs({
@@ -344,6 +373,7 @@ export function useDirectorySettings({
codex: codexDir ?? defaultsRef.current.codex,
gemini: geminiDir ?? defaultsRef.current.gemini,
opencode: opencodeDir ?? defaultsRef.current.opencode,
+ hermes: hermesDir ?? defaultsRef.current.hermes,
});
},
[],
diff --git a/src/hooks/useSettingsForm.ts b/src/hooks/useSettingsForm.ts
index 0944b91d10..24d3b8946b 100644
--- a/src/hooks/useSettingsForm.ts
+++ b/src/hooks/useSettingsForm.ts
@@ -90,6 +90,7 @@ export function useSettingsForm(): UseSettingsFormResult {
codexConfigDir: sanitizeDir(data.codexConfigDir),
geminiConfigDir: sanitizeDir(data.geminiConfigDir),
opencodeConfigDir: sanitizeDir(data.opencodeConfigDir),
+ hermesConfigDir: sanitizeDir(data.hermesConfigDir),
language: normalizedLanguage,
};
@@ -150,6 +151,7 @@ export function useSettingsForm(): UseSettingsFormResult {
codexConfigDir: sanitizeDir(serverData.codexConfigDir),
geminiConfigDir: sanitizeDir(serverData.geminiConfigDir),
opencodeConfigDir: sanitizeDir(serverData.opencodeConfigDir),
+ hermesConfigDir: sanitizeDir(serverData.hermesConfigDir),
language: normalizedLanguage,
};
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 7cfc3f5a6c..51c7fa0e4f 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -167,6 +167,15 @@
"oauthTitle": "OAuth Authentication Mode",
"oauthHint": "Google official uses OAuth personal authentication, no need to fill in API Key. The browser will automatically open for login on first use.",
"apiKeyPlaceholder": "Enter Gemini API Key"
+ },
+ "hermes": {
+ "model": "Model",
+ "authStatusTitle": "Hermes OAuth Status",
+ "authLoggedIn": "Logged in as: {{account}}",
+ "authNotLoggedIn": "Not logged in. Run 'hermes auth login' in terminal to authenticate.",
+ "officialTitle": "Official Provider",
+ "officialHint": "Anthropic official provider uses default settings. No API key required.",
+ "apiKeyPlaceholder": "Enter API Key"
}
}
},
@@ -655,7 +664,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
},
"sessionManager": {
"title": "Session Manager",
@@ -1288,7 +1298,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
}
},
"userLevelPath": "User-level MCP path",
diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json
index 2d867c3948..594aa79134 100644
--- a/src/i18n/locales/ja.json
+++ b/src/i18n/locales/ja.json
@@ -167,6 +167,15 @@
"oauthTitle": "OAuth 認証モード",
"oauthHint": "Google 公式は OAuth 個人認証を使用するため API Key は不要です。初回利用時にブラウザが開きます。",
"apiKeyPlaceholder": "Gemini API Key を入力"
+ },
+ "hermes": {
+ "model": "モデル",
+ "authStatusTitle": "Hermes OAuth ステータス",
+ "authLoggedIn": "ログイン中:{{account}}",
+ "authNotLoggedIn": "未ログインです。ターミナルで 'hermes auth login' を実行して認証してください。",
+ "officialTitle": "公式プロバイダー",
+ "officialHint": "Anthropic 公式プロバイダーはデフォルト設定を使用します。API Key は不要です。",
+ "apiKeyPlaceholder": "API Key を入力"
}
}
},
@@ -655,7 +664,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
},
"sessionManager": {
"title": "セッション管理",
@@ -1288,7 +1298,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
}
},
"userLevelPath": "ユーザーレベルの MCP パス",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index 3a5069b958..00e75d098a 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -167,6 +167,15 @@
"oauthTitle": "OAuth 认证模式",
"oauthHint": "Google 官方使用 OAuth 个人认证,无需填写 API Key。首次使用时会自动打开浏览器进行登录。",
"apiKeyPlaceholder": "请输入 Gemini API Key"
+ },
+ "hermes": {
+ "model": "模型",
+ "authStatusTitle": "Hermes OAuth 状态",
+ "authLoggedIn": "已登录:{{account}}",
+ "authNotLoggedIn": "未登录。请在终端运行 'hermes auth login' 进行认证。",
+ "officialTitle": "官方供应商",
+ "officialHint": "Anthropic 官方供应商使用默认设置,无需 API Key。",
+ "apiKeyPlaceholder": "请输入 API Key"
}
}
},
@@ -655,7 +664,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
},
"sessionManager": {
"title": "会话管理",
@@ -1289,7 +1299,8 @@
"codex": "Codex",
"gemini": "Gemini",
"opencode": "OpenCode",
- "openclaw": "OpenClaw"
+ "openclaw": "OpenClaw",
+ "hermes": "Hermes"
}
},
"userLevelPath": "用户级 MCP 配置路径",
diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts
index 1fbbb30cf8..72209bd425 100644
--- a/src/lib/api/config.ts
+++ b/src/lib/api/config.ts
@@ -1,7 +1,13 @@
// 配置相关 API
import { invoke } from "@tauri-apps/api/core";
-export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim";
+export type AppType =
+ | "claude"
+ | "codex"
+ | "gemini"
+ | "hermes"
+ | "omo"
+ | "omo_slim";
/**
* 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet)
diff --git a/src/lib/api/skills.ts b/src/lib/api/skills.ts
index 456e57b670..3c52630730 100644
--- a/src/lib/api/skills.ts
+++ b/src/lib/api/skills.ts
@@ -2,7 +2,13 @@ import { invoke } from "@tauri-apps/api/core";
import type { AppId } from "@/lib/api/types";
-export type AppType = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
+export type AppType =
+ | "claude"
+ | "codex"
+ | "gemini"
+ | "opencode"
+ | "openclaw"
+ | "hermes";
/** Skill 应用启用状态 */
export interface SkillApps {
@@ -11,6 +17,7 @@ export interface SkillApps {
gemini: boolean;
opencode: boolean;
openclaw: boolean;
+ hermes: boolean;
}
/** 已安装的 Skill(v3.10.0+ 统一结构) */
diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts
index 1fff721fc5..4ec68d6b23 100644
--- a/src/lib/api/types.ts
+++ b/src/lib/api/types.ts
@@ -1,2 +1,8 @@
// 前端统一使用 AppId 作为应用标识(与后端命令参数 `app` 一致)
-export type AppId = "claude" | "codex" | "gemini" | "opencode" | "openclaw";
+export type AppId =
+ | "claude"
+ | "codex"
+ | "gemini"
+ | "opencode"
+ | "openclaw"
+ | "hermes";
diff --git a/src/types.ts b/src/types.ts
index e5fbb7def1..b2fdbc8033 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -178,6 +178,7 @@ export interface VisibleApps {
gemini: boolean;
opencode: boolean;
openclaw: boolean;
+ hermes: boolean;
}
// WebDAV 同步状态
@@ -271,6 +272,8 @@ export interface Settings {
opencodeConfigDir?: string;
// 覆盖 OpenClaw 配置目录(可选)
openclawConfigDir?: string;
+ // 覆盖 Hermes 配置目录(可选)
+ hermesConfigDir?: string;
// ===== 当前供应商 ID(设备级)=====
// 当前 Claude 供应商 ID(优先于数据库 is_current)
@@ -344,6 +347,7 @@ export interface McpApps {
gemini: boolean;
opencode: boolean;
openclaw: boolean;
+ hermes: boolean;
}
// MCP 服务器条目(v3.7.0 统一结构)
@@ -387,6 +391,7 @@ export interface UniversalProviderApps {
claude: boolean;
codex: boolean;
gemini: boolean;
+ hermes: boolean;
}
// Claude 模型配置
diff --git a/src/types/proxy.ts b/src/types/proxy.ts
index 7aa6f42de3..3243090be3 100644
--- a/src/types/proxy.ts
+++ b/src/types/proxy.ts
@@ -47,6 +47,7 @@ export interface ProxyTakeoverStatus {
gemini: boolean;
opencode: boolean;
openclaw: boolean;
+ hermes: boolean;
}
export interface ProviderHealth {
diff --git a/tests/msw/state.ts b/tests/msw/state.ts
index 9768914d92..7e0afb29ab 100644
--- a/tests/msw/state.ts
+++ b/tests/msw/state.ts
@@ -66,6 +66,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
},
opencode: {},
openclaw: {},
+ hermes: {},
});
const createDefaultCurrent = (): CurrentProviderState => ({
@@ -74,6 +75,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
gemini: "gemini-1",
opencode: "",
openclaw: "",
+ hermes: "",
});
let providers = createDefaultProviders();
@@ -153,6 +155,7 @@ let mcpConfigs: McpConfigState = {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
server: {
type: "stdio",
@@ -171,6 +174,7 @@ let mcpConfigs: McpConfigState = {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
server: {
type: "http",
@@ -181,6 +185,7 @@ let mcpConfigs: McpConfigState = {
gemini: {},
opencode: {},
openclaw: {},
+ hermes: {},
};
const cloneProviders = (value: ProvidersByApp) =>
@@ -216,6 +221,7 @@ export const resetProviderState = () => {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
server: {
type: "stdio",
@@ -234,6 +240,7 @@ export const resetProviderState = () => {
gemini: false,
opencode: false,
openclaw: false,
+ hermes: false,
},
server: {
type: "http",
@@ -244,6 +251,7 @@ export const resetProviderState = () => {
gemini: {},
opencode: {},
openclaw: {},
+ hermes: {},
};
};