feat: add Hermes Agent as 6th supported app type (Phase 1)

Register AppType::Hermes across the entire Rust backend:
- Add Hermes variant to AppType enum with additive mode and MCP support
- Add hermes field to McpApps, SkillApps, CommonConfigSnippets, and all
  per-app structs (McpRoot, PromptRoot, VisibleApps, AppSettings)
- Create minimal hermes_config.rs with get_hermes_dir() respecting
  settings override, matching the pattern of other app config modules
- Update all match arms in commands, services, deeplink, proxy, mcp,
  session_manager, and test files
- Extract shared build_additive_app_settings() to eliminate duplication
  between OpenClaw and Hermes deep link handling
- Combine identical OpenClaw/Hermes proxy match arms into unified arms
This commit is contained in:
Jason
2026-04-15 16:22:35 +08:00
parent 701e7d9581
commit 81af0a57f9
27 changed files with 272 additions and 103 deletions
+40 -5
View File
@@ -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
}
}
@@ -86,6 +93,7 @@ impl SkillApps {
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::OpenClaw => false, // OpenClaw doesn't support Skills
AppType::Hermes => false, // Hermes doesn't support Skills yet
}
}
@@ -97,6 +105,7 @@ impl SkillApps {
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::OpenClaw => {} // OpenClaw doesn't support Skills, ignore
AppType::Hermes => {} // Hermes doesn't support Skills yet, ignore
}
}
@@ -251,6 +260,9 @@ pub struct McpRoot {
/// OpenClaw MCP 配置(v4.1.0+,实际使用 openclaw.json
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
/// Hermes MCP 配置(实际使用 config.yaml
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub hermes: McpConfig,
}
impl Default for McpRoot {
@@ -264,6 +276,7 @@ impl Default for McpRoot {
gemini: McpConfig::default(),
opencode: McpConfig::default(),
openclaw: McpConfig::default(),
hermes: McpConfig::default(),
}
}
}
@@ -288,6 +301,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 +319,7 @@ pub enum AppType {
Gemini,
OpenCode,
OpenClaw,
Hermes,
}
impl AppType {
@@ -314,15 +330,19 @@ 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)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw)
/// - Additive mode (true): All providers are written to live config (OpenCode, OpenClaw, Hermes)
pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
matches!(
self,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
)
}
/// Return an iterator over all app types
@@ -333,6 +353,7 @@ impl AppType {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
]
.into_iter()
}
@@ -349,10 +370,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 +397,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,
}
impl CommonConfigSnippets {
@@ -386,6 +411,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 +423,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 +465,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 +626,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 +638,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 +654,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 +676,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 +690,7 @@ impl MultiAppConfig {
AppType::Gemini,
AppType::OpenCode,
AppType::OpenClaw,
AppType::Hermes,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
@@ -729,6 +762,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 +803,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 didn't exist in v3.6.x, skip
};
for (id, entry) in old_servers {
+11
View File
@@ -101,6 +101,15 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
Ok(ConfigStatus { exists, path })
}
AppType::Hermes => {
let config_path = crate::hermes_config::get_hermes_config_path();
let exists = config_path.exists();
let path = crate::hermes_config::get_hermes_dir()
.to_string_lossy()
.to_string();
Ok(ConfigStatus { exists, path })
}
}
}
@@ -117,6 +126,7 @@ pub async fn get_config_dir(app: String) -> Result<String, String> {
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::hermes_config::get_hermes_dir(),
};
Ok(dir.to_string_lossy().to_string())
@@ -130,6 +140,7 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool,
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::hermes_config::get_hermes_dir(),
};
if !config_dir.exists() {
+1
View File
@@ -46,6 +46,7 @@ impl Database {
codex: enabled_codex,
gemini: enabled_gemini,
opencode: enabled_opencode,
hermes: false,
},
description,
homepage,
+5
View File
@@ -167,6 +167,7 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
codex: false,
gemini: false,
opencode: false,
hermes: false,
};
for app in apps_str.split(',') {
@@ -179,6 +180,10 @@ pub(crate) fn parse_mcp_apps(apps_str: &str) -> Result<McpApps, AppError> {
// OpenClaw doesn't support MCP, ignore silently
log::debug!("OpenClaw doesn't support MCP, ignoring in apps parameter");
}
"hermes" => {
// TODO: Hermes MCP sync not yet implemented, ignore silently for now
log::debug!("Hermes MCP sync not yet implemented, ignoring in apps parameter");
}
other => {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': {other}"
+6 -6
View File
@@ -81,10 +81,10 @@ fn parse_provider_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -190,10 +190,10 @@ fn parse_prompt_deeplink(
// Validate app type
if !matches!(
app.as_str(),
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{app}'"
"Invalid app type: must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{app}'"
)));
}
@@ -262,10 +262,10 @@ fn parse_mcp_deeplink(
let trimmed = app.trim();
if !matches!(
trimmed,
"claude" | "codex" | "gemini" | "opencode" | "openclaw"
"claude" | "codex" | "gemini" | "opencode" | "openclaw" | "hermes"
) {
return Err(AppError::InvalidInput(format!(
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', or 'openclaw', got '{trimmed}'"
"Invalid app in 'apps': must be 'claude', 'codex', 'gemini', 'opencode', 'openclaw', or 'hermes', got '{trimmed}'"
)));
}
}
+5 -7
View File
@@ -146,7 +146,7 @@ pub(crate) fn build_provider_from_request(
AppType::Codex => build_codex_settings(request),
AppType::Gemini => build_gemini_settings(request),
AppType::OpenCode => build_opencode_settings(request),
AppType::OpenClaw => build_openclaw_settings(request),
AppType::OpenClaw | AppType::Hermes => build_additive_app_settings(request),
};
// Build usage script configuration if provided
@@ -393,11 +393,11 @@ fn build_opencode_settings(request: &DeepLinkImportRequest) -> serde_json::Value
})
}
fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
/// Build settings for additive-mode apps (OpenClaw, Hermes)
/// Format: { baseUrl, apiKey, api, models }
fn build_additive_app_settings(request: &DeepLinkImportRequest) -> serde_json::Value {
let endpoint = get_primary_endpoint(request);
// Build OpenClaw provider config
// Format: { baseUrl, apiKey, api, models }
let mut config = serde_json::Map::new();
if !endpoint.is_empty() {
@@ -408,10 +408,8 @@ fn build_openclaw_settings(request: &DeepLinkImportRequest) -> serde_json::Value
config.insert("apiKey".to_string(), json!(api_key));
}
// Default to OpenAI-compatible API
config.insert("api".to_string(), json!("openai-completions"));
// Build models array
if let Some(model) = &request.model {
config.insert(
"models".to_string(),
@@ -484,7 +482,7 @@ pub fn parse_and_merge_config(
"codex" => merge_codex_config(&mut merged, &config_value)?,
"gemini" => merge_gemini_config(&mut merged, &config_value)?,
// Additive mode apps use JSON config directly; pass through as-is
"openclaw" | "opencode" => {
"openclaw" | "opencode" | "hermes" => {
merge_additive_config(&mut merged, &config_value)?;
}
"" => {
+31
View File
@@ -0,0 +1,31 @@
//! Hermes Agent 配置文件读写模块
//!
//! 处理 `~/.hermes/config.yaml` 配置文件的读写操作(YAML 格式)。
//! Hermes 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。
use crate::settings::get_hermes_override_dir;
use std::path::PathBuf;
// ============================================================================
// Path Functions
// ============================================================================
/// 获取 Hermes 配置目录
///
/// 默认路径: `~/.hermes/`
/// 可通过 settings.hermes_config_dir 覆盖
pub fn get_hermes_dir() -> PathBuf {
if let Some(override_dir) = get_hermes_override_dir() {
return override_dir;
}
crate::config::get_home_dir().join(".hermes")
}
/// 获取 Hermes 配置文件路径
///
/// 返回 `~/.hermes/config.yaml`
pub fn get_hermes_config_path() -> PathBuf {
get_hermes_dir().join("config.yaml")
}
+2
View File
@@ -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")]
@@ -639,6 +640,7 @@ pub fn run() {
crate::app_config::AppType::Gemini,
crate::app_config::AppType::OpenCode,
crate::app_config::AppType::OpenClaw,
crate::app_config::AppType::Hermes,
] {
match crate::services::prompt::PromptService::import_from_file_on_first_launch(
&app_state,
+1
View File
@@ -92,6 +92,7 @@ pub fn import_from_claude(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -236,6 +236,7 @@ pub fn import_from_codex(config: &mut MultiAppConfig) -> Result<usize, AppError>
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -88,6 +88,7 @@ pub fn import_from_gemini(config: &mut MultiAppConfig) -> Result<usize, AppError
codex: false,
gemini: true,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -259,6 +259,7 @@ pub fn import_from_opencode(config: &mut MultiAppConfig) -> Result<usize, AppErr
codex: false,
gemini: false,
opencode: true,
hermes: false,
},
description: None,
homepage: None,
+2 -2
View File
@@ -16,14 +16,14 @@ pub fn prompt_file_path(app: &AppType) -> Result<PathBuf, AppError> {
AppType::Gemini => get_gemini_dir(),
AppType::OpenCode => get_opencode_dir(),
AppType::OpenClaw => get_openclaw_dir(),
AppType::Hermes => crate::hermes_config::get_hermes_dir(),
};
let filename = match app {
AppType::Claude => "CLAUDE.md",
AppType::Codex => "AGENTS.md",
AppType::Gemini => "GEMINI.md",
AppType::OpenCode => "AGENTS.md",
AppType::OpenClaw => "AGENTS.md", // OpenClaw uses AGENTS.md for agent instructions
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => "AGENTS.md",
};
Ok(base_dir.join(filename))
+5 -13
View File
@@ -174,13 +174,9 @@ impl ProviderType {
}
ProviderType::Gemini
}
AppType::OpenCode => {
// OpenCode doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, but return a default type for completeness
ProviderType::Codex // Fallback to Codex-like type
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex-like type
ProviderType::Codex
}
}
}
@@ -232,12 +228,8 @@ pub fn get_adapter(app_type: &AppType) -> Box<dyn ProviderAdapter> {
AppType::Claude => Box::new(ClaudeAdapter::new()),
AppType::Codex => Box::new(CodexAdapter::new()),
AppType::Gemini => Box::new(GeminiAdapter::new()),
AppType::OpenCode => {
// OpenCode doesn't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy, fallback to Codex adapter
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy, fallback to Codex adapter
Box::new(CodexAdapter::new())
}
}
+3
View File
@@ -130,6 +130,9 @@ impl ConfigService {
// OpenClaw uses additive mode, no live sync needed
// OpenClaw providers are managed directly in the config file
}
AppType::Hermes => {
// Hermes uses additive mode, no live sync needed
}
}
Ok(())
+9 -1
View File
@@ -128,6 +128,10 @@ impl McpService {
// Skip for now
log::debug!("OpenClaw MCP support is still in development, skipping sync");
}
AppType::Hermes => {
// TODO: Hermes MCP sync not yet implemented
log::debug!("Hermes MCP sync not yet implemented, skipping sync");
}
}
Ok(())
}
@@ -157,6 +161,10 @@ impl McpService {
// OpenClaw MCP support is still in development
log::debug!("OpenClaw MCP support is still in development, skipping remove");
}
AppType::Hermes => {
// TODO: Hermes MCP sync not yet implemented
log::debug!("Hermes MCP sync not yet implemented, skipping remove");
}
}
Ok(())
}
@@ -166,7 +174,7 @@ impl McpService {
let servers = Self::get_all_servers(state)?;
for app in AppType::all() {
if matches!(app, AppType::OpenClaw) {
if matches!(app, AppType::OpenClaw | AppType::Hermes) {
continue;
}
+28 -5
View File
@@ -42,6 +42,10 @@ pub(crate) fn provider_exists_in_live_config(
.map(|providers| providers.contains_key(provider_id)),
AppType::OpenClaw => crate::openclaw_config::get_providers()
.map(|providers| providers.contains_key(provider_id)),
AppType::Hermes => {
// TODO: hermes_config module not yet implemented
Ok(false)
}
_ => Ok(false),
}
}
@@ -345,7 +349,7 @@ fn settings_contain_common_config(app_type: &AppType, settings: &Value, snippet:
}
_ => false,
},
AppType::OpenCode | AppType::OpenClaw => false,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => false,
}
}
@@ -415,7 +419,7 @@ pub(crate) fn remove_common_config_from_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
}
}
@@ -470,7 +474,7 @@ fn apply_common_config_to_settings(
}
Ok(result)
}
AppType::OpenCode | AppType::OpenClaw => Ok(settings.clone()),
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => Ok(settings.clone()),
}
}
@@ -790,6 +794,13 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
}
}
}
AppType::Hermes => {
// TODO: hermes_config module not yet implemented
log::debug!(
"Hermes provider '{}' write to live config not yet implemented",
provider.id
);
}
}
Ok(())
}
@@ -985,6 +996,18 @@ pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
let config = read_openclaw_config()?;
Ok(config)
}
AppType::Hermes => {
let config_path = crate::hermes_config::get_hermes_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"hermes.config.missing",
"Hermes 配置文件不存在",
"Hermes configuration file not found",
));
}
// Return empty object until hermes_config is implemented
Ok(json!({}))
}
}
}
@@ -1067,8 +1090,8 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
"config": config_obj
})
}
// OpenCode and OpenClaw use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw => {
// OpenCode, OpenClaw and Hermes use additive mode and are handled by early return above
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
unreachable!("additive mode apps are handled by early return")
}
};
+28 -2
View File
@@ -1282,6 +1282,12 @@ impl ProviderService {
match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(id)?,
AppType::OpenClaw => remove_openclaw_provider_from_live(id)?,
AppType::Hermes => {
// TODO: hermes_config module not yet implemented
log::debug!(
"Hermes provider '{id}' removal from live config not yet implemented"
);
}
_ => {}
}
}
@@ -1344,6 +1350,10 @@ impl ProviderService {
AppType::OpenClaw => {
remove_openclaw_provider_from_live(id)?;
}
AppType::Hermes => {
// TODO: hermes_config module not yet implemented
log::debug!("Hermes provider '{id}' removal from live config not yet implemented");
}
_ => {
return Err(AppError::Message(format!(
"App {} does not support remove from live config",
@@ -1532,6 +1542,10 @@ impl ProviderService {
let rollback_result = match app_type {
AppType::OpenCode => remove_opencode_provider_from_live(&provider.id),
AppType::OpenClaw => remove_openclaw_provider_from_live(&provider.id),
AppType::Hermes => {
// TODO: hermes_config module not yet implemented
Ok(())
}
_ => Ok(()),
};
@@ -1708,6 +1722,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 => Ok(String::new()), // Hermes doesn't use common config snippets
}
}
@@ -1722,6 +1737,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 => Ok(String::new()), // Hermes doesn't use common config snippets
}
}
@@ -2087,6 +2103,16 @@ impl ProviderService {
));
}
}
AppType::Hermes => {
// Hermes: accept any JSON object for now
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)
@@ -2258,8 +2284,8 @@ impl ProviderService {
Ok((api_key, base_url))
}
AppType::OpenClaw => {
// OpenClaw uses apiKey and baseUrl directly on the object
AppType::OpenClaw | AppType::Hermes => {
// OpenClaw/Hermes use apiKey and baseUrl directly on the object
let api_key = provider
.settings_config
.get("apiKey")
+23 -56
View File
@@ -466,13 +466,9 @@ impl ProxyService {
AppType::Claude => self.read_claude_live()?,
AppType::Codex => self.read_codex_live()?,
AppType::Gemini => self.read_gemini_live()?,
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
};
@@ -687,11 +683,8 @@ impl ProxyService {
}
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features, skip silently
}
}
@@ -871,13 +864,9 @@ impl ProxyService {
AppType::Claude => ("claude", self.read_claude_live()?),
AppType::Codex => ("codex", self.read_codex_live()?),
AppType::Gemini => ("gemini", self.read_gemini_live()?),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
};
@@ -1019,13 +1008,9 @@ impl ProxyService {
self.write_gemini_live(&live_config)?;
log::info!("Gemini Live 配置已接管,代理地址: {proxy_url}");
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features
return Err("OpenCode 不支持代理功能".to_string());
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
return Err("OpenClaw 不支持代理功能".to_string());
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features
return Err("该应用不支持代理功能".to_string());
}
}
@@ -1076,11 +1061,8 @@ impl ProxyService {
let _ = self.write_gemini_live(&live_config);
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features, skip silently
}
}
@@ -1119,11 +1101,8 @@ impl ProxyService {
log::info!("Gemini Live 配置已恢复");
}
}
AppType::OpenCode => {
// OpenCode doesn't support proxy features, skip silently
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features, skip silently
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features, skip silently
}
}
@@ -1213,13 +1192,9 @@ impl ProxyService {
AppType::Claude => self.write_claude_live(config),
AppType::Codex => self.write_codex_live(config),
AppType::Gemini => self.write_gemini_live(config),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Err("OpenCode 不支持代理功能".to_string())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
Err("OpenClaw 不支持代理功能".to_string())
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features
Err("该应用不支持代理功能".to_string())
}
}
}
@@ -1238,12 +1213,8 @@ impl ProxyService {
Ok(config) => Self::is_gemini_live_taken_over(&config),
Err(_) => false,
},
AppType::OpenCode => {
// OpenCode doesn't support proxy takeover
false
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy takeover
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy takeover
false
}
}
@@ -1285,12 +1256,8 @@ impl ProxyService {
AppType::Claude => self.cleanup_claude_takeover_placeholders_in_live(),
AppType::Codex => self.cleanup_codex_takeover_placeholders_in_live(),
AppType::Gemini => self.cleanup_gemini_takeover_placeholders_in_live(),
AppType::OpenCode => {
// OpenCode doesn't support proxy features
Ok(())
}
AppType::OpenClaw => {
// OpenClaw doesn't support proxy features
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes => {
// These apps don't support proxy features
Ok(())
}
}
@@ -1540,7 +1507,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}"));
}
};
+6
View File
@@ -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 => crate::hermes_config::get_hermes_dir().join("skills"),
})
}
+20 -6
View File
@@ -207,7 +207,10 @@ impl StreamCheckService {
// OpenCode / OpenClaw 的 settings_config 结构与 Claude/Codex/Gemini 不同
// baseUrl / apiKey 直接作为根字段而非嵌套在 env),并且协议由 `api`
// 或 `npm` 字段显式指定。它们不走 get_adapter 路径,而是直接分发。
if matches!(app_type, AppType::OpenCode | AppType::OpenClaw) {
if matches!(
app_type,
AppType::OpenCode | AppType::OpenClaw | AppType::Hermes
) {
return Self::check_once_without_adapter(app_type, provider, config, start).await;
}
@@ -270,9 +273,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 处理")
}
};
@@ -700,7 +703,18 @@ impl StreamCheckService {
)
.await
}
_ => unreachable!("check_once_without_adapter 只处理 OpenCode/OpenClaw"),
AppType::Hermes => {
// Hermes uses the same check path as OpenClaw for now
Self::check_openclaw_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;
@@ -1241,8 +1255,8 @@ impl StreamCheckService {
// Try to extract first model from the models object
Self::extract_opencode_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
AppType::OpenClaw => {
// OpenClaw uses models array in settings_config
AppType::OpenClaw | AppType::Hermes => {
// OpenClaw/Hermes use models array in settings_config
// Try to extract first model from the models array
Self::extract_openclaw_model(provider).unwrap_or_else(|| "gpt-4o".to_string())
}
+3
View File
@@ -100,6 +100,7 @@ pub fn load_messages(provider_id: &str, source_path: &str) -> Result<Vec<Session
"opencode" => opencode::load_messages(path),
"openclaw" => openclaw::load_messages(path),
"gemini" => gemini::load_messages(path),
"hermes" => Err("Hermes session loading not yet implemented".to_string()),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -150,6 +151,7 @@ fn delete_session_with_root(
"opencode" => opencode::delete_session(&validated_root, &validated_source, session_id),
"openclaw" => openclaw::delete_session(&validated_root, &validated_source, session_id),
"gemini" => gemini::delete_session(&validated_root, &validated_source, session_id),
"hermes" => Err("Hermes session deletion not yet implemented".to_string()),
_ => Err(format!("Unsupported provider: {provider_id}")),
}
}
@@ -161,6 +163,7 @@ fn provider_root(provider_id: &str) -> Result<PathBuf, String> {
"opencode" => opencode::get_opencode_data_dir(),
"openclaw" => crate::openclaw_config::get_openclaw_dir().join("agents"),
"gemini" => crate::gemini_config::get_gemini_dir().join("tmp"),
"hermes" => crate::hermes_config::get_hermes_dir().join("sessions"),
_ => return Err(format!("Unsupported provider: {provider_id}")),
};
+28
View File
@@ -36,6 +36,8 @@ pub struct VisibleApps {
pub opencode: bool,
#[serde(default = "default_true")]
pub openclaw: bool,
#[serde(default)]
pub hermes: bool,
}
impl Default for VisibleApps {
@@ -46,6 +48,7 @@ impl Default for VisibleApps {
gemini: true,
opencode: true,
openclaw: true,
hermes: false, // 默认不显示,需用户手动启用
}
}
}
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes_config_dir: Option<String>,
// ===== 当前供应商 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<String>,
/// 当前 Hermes 供应商 ID(本地存储,保持结构一致)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_provider_hermes: Option<String>,
// ===== 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<PathBuf> {
.map(|p| resolve_override_path(p))
}
pub fn get_hermes_override_dir() -> Option<PathBuf> {
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<String> {
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(),
})
}
+2
View File
@@ -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,
+8
View File
@@ -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,
+1
View File
@@ -75,6 +75,7 @@ command = "say"
codex: true, // 启用 Codex
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,
+1
View File
@@ -163,6 +163,7 @@ command = "say"
codex: true,
gemini: false,
opencode: false,
hermes: false,
},
description: None,
homepage: None,