From 78c69445d0568a9f33c412fce0f3e6bb46b5f736 Mon Sep 17 00:00:00 2001 From: Daniel Zaharia Date: Tue, 2 Jun 2026 10:48:15 +0300 Subject: [PATCH 01/12] feat(agents): add Hermes agent support Adds Hermes (by Nous Research) as a fully supported agent alongside the existing 8. Hermes is detected at ~/.hermes and manages skills organized into named category subdirectories (e.g. apple/, devops/, github/). Rust (hk-core): - New HermesAdapter: scans ~/.hermes/skills/{category}/ across all category dirs; global write target is ~/.hermes/skills/local by convention; MCP servers read from ~/.hermes/config.yaml (YAML format, both URL-based and command-based entries); no hook or plugin support. - McpFormat::HermesYaml + serde_yaml dependency: full deploy/remove/ restore/read support for Hermes's YAML config format. - service::install_to_agent gains hermes_category: Option<&str> so cross-agent deploys can target any category directory, not just local. - install_from_local and install_from_marketplace also accept hermes_category in all command handlers (Tauri + web). - list_hermes_categories command/route exposes available category dirs. - Project marker: .hermes/skills/local (HK-managed convention). Frontend: - Official Hermes SVG mascot with float/spin animations. - Hermes added to AGENT_ORDER, display names, and onboarding scatter. - Category picker (pill row + New input) in all three install paths: install dialog (local/git), marketplace agent buttons, and extension detail panel cross-agent deploy. - Extension detail: new effect refreshes skill locations and loads content for newly-added instances immediately after cross-agent install, without navigating away. --- Cargo.lock | 20 + crates/hk-core/Cargo.toml | 1 + crates/hk-core/src/adapter/hermes.rs | 403 ++++++++++++++++++ crates/hk-core/src/adapter/mod.rs | 21 +- crates/hk-core/src/deployer.rs | 133 ++++++ crates/hk-core/src/kits/install_plan.rs | 11 + crates/hk-core/src/service.rs | 28 +- crates/hk-desktop/src/commands/install.rs | 58 ++- crates/hk-desktop/src/commands/marketplace.rs | 31 +- crates/hk-desktop/src/main.rs | 1 + crates/hk-web/src/handlers/install.rs | 79 +++- crates/hk-web/src/router.rs | 1 + .../extensions/extension-detail.tsx | 144 ++++++- src/components/extensions/install-dialog.tsx | 81 ++++ src/components/onboarding/onboarding.tsx | 2 + .../shared/agent-mascot/agent-mascot.tsx | 6 + .../shared/agent-mascot/hermes-mascot.tsx | 23 + src/components/shared/agent-mascot/mascot.css | 35 ++ src/lib/invoke.ts | 26 +- src/lib/types.ts | 2 + src/pages/marketplace.tsx | 84 +++- src/stores/extension-store.ts | 6 +- src/stores/marketplace-store.ts | 4 +- 23 files changed, 1153 insertions(+), 47 deletions(-) create mode 100644 crates/hk-core/src/adapter/hermes.rs create mode 100644 src/components/shared/agent-mascot/hermes-mascot.tsx diff --git a/Cargo.lock b/Cargo.lock index ec8b1855..9ef31c66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1865,6 +1865,7 @@ dependencies = [ "semver", "serde", "serde_json", + "serde_yaml", "serial_test", "sha2", "tempfile", @@ -4408,6 +4409,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.13.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -5655,6 +5669,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/hk-core/Cargo.toml b/crates/hk-core/Cargo.toml index 101984dc..06fb5aa7 100644 --- a/crates/hk-core/Cargo.toml +++ b/crates/hk-core/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true [dependencies] serde.workspace = true serde_json.workspace = true +serde_yaml = "0.9" anyhow.workspace = true chrono.workspace = true uuid.workspace = true diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs new file mode 100644 index 00000000..db5f705d --- /dev/null +++ b/crates/hk-core/src/adapter/hermes.rs @@ -0,0 +1,403 @@ +// Config reference: https://hermes-agent.nousresearch.com/docs/user-guide/features/skills +// Skills: ~/.hermes/skills/{category}/{skill-name}/SKILL.md +// - "local" is the conventional category for user-managed skills (e.g. installed by HK) +// - Nous ships built-in skills in sibling category dirs (apple/, devops/, etc.) +// MCP: ~/.hermes/config.yaml — "mcp_servers" YAML mapping +// Hooks: not supported (empty ~/.hermes/hooks/ dir, no documentation) +// Plugins: ~/.hermes/hermes-agent/plugins/ contains internal model-provider adapters — +// not user-installable extensions in the HK sense. + +use super::{AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, ProjectMarker}; +use std::path::{Path, PathBuf}; + +pub struct HermesAdapter { + home: PathBuf, +} + +impl Default for HermesAdapter { + fn default() -> Self { + Self::new() + } +} + +impl HermesAdapter { + pub fn new() -> Self { + Self { + home: dirs::home_dir().unwrap_or_default(), + } + } + + #[cfg(test)] + pub fn with_home(home: PathBuf) -> Self { + Self { home } + } + + /// List all category subdirectory names under `~/.hermes/skills/`. + /// Returns sorted names, excluding hidden dirs. Used by the UI category picker. + pub fn list_categories(&self) -> Vec { + let skills_root = self.base_dir().join("skills"); + let mut cats: Vec = std::fs::read_dir(&skills_root) + .ok() + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let p = e.path(); + if !p.is_dir() { + return None; + } + let name = p.file_name()?.to_str()?.to_string(); + if name.starts_with('.') { + return None; + } + Some(name) + }) + .collect(); + cats.sort(); + cats + } + + /// Resolve the target skill directory for a given scope and optional category override. + /// When `category` is provided, use `~/.hermes/skills/{category}` (global) or + /// `.hermes/skills/{category}` (project) instead of the default "local" category. + pub fn skill_dir_for_category( + &self, + scope: &crate::models::ConfigScope, + category: &str, + ) -> std::path::PathBuf { + match scope { + crate::models::ConfigScope::Global => { + self.base_dir().join("skills").join(category) + } + crate::models::ConfigScope::Project { path, .. } => { + std::path::Path::new(path) + .join(".hermes") + .join("skills") + .join(category) + } + } + } + + fn parse_yaml(path: &Path) -> Option { + let content = std::fs::read_to_string(path).ok()?; + serde_yaml::from_str(&content).ok() + } +} + +impl AgentAdapter for HermesAdapter { + fn name(&self) -> &str { + "hermes" + } + + fn base_dir(&self) -> PathBuf { + self.home.join(".hermes") + } + + fn detect(&self) -> bool { + self.base_dir().exists() + } + + fn skill_dirs(&self) -> Vec { + let skills_root = self.base_dir().join("skills"); + // "local" is the user-managed category: written first so skill_dir_for(Global) + // returns it as the install target. Built-in Nous categories follow so the + // scanner picks up all skills across all categories. + let mut dirs = vec![skills_root.join("local")]; + + if let Ok(entries) = std::fs::read_dir(&skills_root) { + let mut extra: Vec = entries + .flatten() + .filter_map(|e| { + let p = e.path(); + if p.is_dir() + && p.file_name().and_then(|n| n.to_str()) != Some("local") + { + Some(p) + } else { + None + } + }) + .collect(); + extra.sort(); + dirs.extend(extra); + } + + // Also include any external dirs configured in config.yaml + let config_path = self.base_dir().join("config.yaml"); + if let Some(config) = Self::parse_yaml(&config_path) { + if let Some(external) = config + .get("skills") + .and_then(|s| s.get("external_dirs")) + .and_then(|v| v.as_sequence()) + { + for item in external { + if let Some(raw) = item.as_str() { + let path = if let Some(stripped) = raw.strip_prefix("~/") { + self.home.join(stripped) + } else { + PathBuf::from(raw) + }; + if path.is_dir() && !dirs.contains(&path) { + dirs.push(path); + } + } + } + } + } + + dirs + } + + fn project_skill_dirs(&self) -> Vec { + // "local" sub-category mirrors the global convention: user-managed skills + // land in .hermes/skills/local/, keeping them separate from any Nous-shipped + // category dirs that might appear in a future project-level skills feature. + vec![".hermes/skills/local".into()] + } + + fn mcp_config_path(&self) -> PathBuf { + self.base_dir().join("config.yaml") + } + + fn mcp_format(&self) -> McpFormat { + McpFormat::HermesYaml + } + + fn read_mcp_servers(&self) -> Vec { + self.read_mcp_servers_from(&self.mcp_config_path()) + } + + fn read_mcp_servers_from(&self, path: &Path) -> Vec { + let Some(config) = Self::parse_yaml(path) else { + return vec![]; + }; + let Some(servers) = config + .get("mcp_servers") + .and_then(|v| v.as_mapping()) + else { + return vec![]; + }; + + servers + .iter() + .filter_map(|(key, val)| { + let name = key.as_str()?.to_string(); + // HTTP MCP: {url: "http://..."} — store URL in command field + // stdio MCP: {command: "...", args: [...], env: {...}} + let command = if let Some(url) = val.get("url").and_then(|v| v.as_str()) { + url.to_string() + } else { + val.get("command").and_then(|v| v.as_str())?.to_string() + }; + + let args: Vec = val + .get("args") + .and_then(|v| v.as_sequence()) + .map(|seq| { + seq.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let env: std::collections::HashMap = val + .get("env") + .and_then(|v| v.as_mapping()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| { + Some((k.as_str()?.to_string(), v.as_str()?.to_string())) + }) + .collect() + }) + .unwrap_or_default(); + + let enabled = val + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + Some(McpServerEntry { + name, + command, + args, + env, + enabled, + }) + }) + .collect() + } + + fn hook_format(&self) -> HookFormat { + HookFormat::None + } + + fn hook_config_path(&self) -> PathBuf { + // Placeholder — never read or written since hook_format() == None. + self.base_dir().join("hooks.unused") + } + + fn read_hooks(&self) -> Vec { + vec![] + } + + fn plugin_dirs(&self) -> Vec { + vec![] + } + + fn list_skill_categories(&self) -> Vec { + self.list_categories() + } + + // --- Config file discovery (Agents page) --- + + fn global_rules_files(&self) -> Vec { + // SOUL.md defines the agent's personality / system prompt baseline + vec![self.base_dir().join("SOUL.md")] + } + + fn global_memory_files(&self) -> Vec { + let memories_dir = self.base_dir().join("memories"); + std::fs::read_dir(&memories_dir) + .ok() + .into_iter() + .flatten() + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_file()) + .collect() + } + + fn global_settings_files(&self) -> Vec { + vec![self.base_dir().join("config.yaml")] + } + + fn project_markers(&self) -> Vec { + // Hermes has no native project config convention. The marker is the + // directory HarnessKit itself creates when installing project skills, + // so existing HK-managed projects are recognized on re-scan. + vec![ProjectMarker::Dir(".hermes/skills/local")] + } + + fn project_mcp_config_relpath(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::super::AgentAdapter; + use super::*; + use std::fs; + + #[test] + fn test_name() { + let adapter = HermesAdapter::new(); + assert_eq!(adapter.name(), "hermes"); + } + + #[test] + fn test_detect_without_dir() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + assert!(!adapter.detect()); + } + + #[test] + fn test_detect_with_dir() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir_all(tmp.path().join(".hermes")).unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + assert!(adapter.detect()); + } + + #[test] + fn test_skill_dirs_local_first() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let dirs = adapter.skill_dirs(); + assert!(!dirs.is_empty()); + assert!( + dirs[0].ends_with(".hermes/skills/local"), + "first skill dir should be local category, got {:?}", + dirs[0] + ); + } + + #[test] + fn test_skill_dirs_includes_category_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let skills = tmp.path().join(".hermes").join("skills"); + fs::create_dir_all(skills.join("local")).unwrap(); + fs::create_dir_all(skills.join("devops")).unwrap(); + fs::create_dir_all(skills.join("apple")).unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let dirs = adapter.skill_dirs(); + // local is first + assert!(dirs[0].ends_with("local")); + // other categories are included + let names: Vec<&str> = dirs + .iter() + .filter_map(|p| p.file_name()?.to_str()) + .collect(); + assert!(names.contains(&"devops")); + assert!(names.contains(&"apple")); + } + + #[test] + fn test_project_skill_dirs() { + let adapter = HermesAdapter::new(); + assert_eq!(adapter.project_skill_dirs(), vec![".hermes/skills/local"]); + } + + #[test] + fn test_read_hooks_empty() { + let adapter = HermesAdapter::new(); + assert!(adapter.read_hooks().is_empty()); + } + + #[test] + fn test_read_mcp_servers_url_entry() { + let tmp = tempfile::tempdir().unwrap(); + let hermes_dir = tmp.path().join(".hermes"); + fs::create_dir_all(&hermes_dir).unwrap(); + fs::write( + hermes_dir.join("config.yaml"), + "mcp_servers:\n proxy:\n url: http://localhost:8080/mcp\n enabled: false\n", + ) + .unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let servers = adapter.read_mcp_servers(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "proxy"); + assert_eq!(servers[0].command, "http://localhost:8080/mcp"); + assert!(!servers[0].enabled); + } + + #[test] + fn test_read_mcp_servers_command_entry() { + let tmp = tempfile::tempdir().unwrap(); + let hermes_dir = tmp.path().join(".hermes"); + fs::create_dir_all(&hermes_dir).unwrap(); + fs::write( + hermes_dir.join("config.yaml"), + "mcp_servers:\n fs:\n command: /usr/local/bin/mcp-fs\n args:\n - --root\n - /tmp\n env:\n DEBUG: \"1\"\n", + ) + .unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let servers = adapter.read_mcp_servers(); + assert_eq!(servers.len(), 1); + assert_eq!(servers[0].name, "fs"); + assert_eq!(servers[0].command, "/usr/local/bin/mcp-fs"); + assert_eq!(servers[0].args, vec!["--root", "/tmp"]); + assert_eq!(servers[0].env.get("DEBUG").map(|s| s.as_str()), Some("1")); + assert!(servers[0].enabled); + } + + #[test] + fn test_read_mcp_servers_empty_when_no_config() { + let tmp = tempfile::tempdir().unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + assert!(adapter.read_mcp_servers().is_empty()); + } +} diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index d5500ee3..e59a1564 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -4,6 +4,7 @@ pub mod codex; pub mod copilot; pub mod cursor; pub mod gemini; +pub mod hermes; pub mod hook_events; pub mod opencode; pub mod windsurf; @@ -125,6 +126,10 @@ pub enum McpFormat { /// extra fields may be written. /// See https://opencode.ai/config.json (McpLocalConfig). Opencode, + /// YAML config.yaml with "mcp_servers" top-level key (Hermes). + /// Each entry may be URL-based ({url: "..."}) or command-based ({command: "..."}). + /// URL-based entries are stored with the URL in the `command` field and empty args. + HermesYaml, } pub trait AgentAdapter: Send + Sync { @@ -307,6 +312,13 @@ pub trait AgentAdapter: Send + Sync { vec![] } + /// List available skill category names for agents that organise skills + /// into subdirectories (e.g. Hermes: `~/.hermes/skills/{category}/`). + /// Returns an empty vec for agents that use a flat skill directory. + fn list_skill_categories(&self) -> Vec { + vec![] + } + /// Resolve the MCP config file for a given scope. /// - `Global` → adapter's user-scope path (`mcp_config_path()`). /// - `Project` → `/`, or `None` @@ -370,6 +382,7 @@ pub fn all_adapters() -> Vec> { Box::new(copilot::CopilotAdapter::new()), Box::new(windsurf::WindsurfAdapter::new()), Box::new(opencode::OpencodeAdapter::new()), + Box::new(hermes::HermesAdapter::new()), ] } @@ -378,9 +391,9 @@ mod tests { use super::*; #[test] - fn test_all_adapters_returns_eight() { + fn test_all_adapters_returns_nine() { let adapters = all_adapters(); - assert_eq!(adapters.len(), 8); + assert_eq!(adapters.len(), 9); let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect(); assert!(names.contains(&"claude")); assert!(names.contains(&"cursor")); @@ -390,6 +403,7 @@ mod tests { assert!(names.contains(&"copilot")); assert!(names.contains(&"windsurf")); assert!(names.contains(&"opencode")); + assert!(names.contains(&"hermes")); } #[test] @@ -409,7 +423,7 @@ mod tests { // setups). Adding an agent here without a confirmed PATH bug would // unnecessarily rewrite users' mcp_config.json with absolute paths, // hurting cross-machine portability. - for name in ["claude", "codex", "gemini", "cursor", "copilot", "opencode"] { + for name in ["claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes"] { assert!( !by_name[name].needs_path_injection(), "{name} should not need path injection" @@ -503,6 +517,7 @@ mod tests { ("antigravity", ".agents/skills"), // 1.18.4+ canonical; .agent/ kept as backward-compat alias ("copilot", ".github/skills"), ("opencode", ".opencode/skills"), + ("hermes", ".hermes/skills/local"), // user-managed category; built-ins are in sibling category dirs ] .into_iter() .collect(); diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index a2105869..3fe5bf71 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -150,6 +150,9 @@ fn json_top_key(format: McpFormat) -> &'static str { McpFormat::Opencode => { unreachable!("Opencode format routes through dedicated CST helpers") } + McpFormat::HermesYaml => { + unreachable!("HermesYaml format routes through dedicated YAML helpers") + } } } @@ -165,6 +168,7 @@ pub fn deploy_mcp_server( McpFormat::Servers => deploy_mcp_server_json(config_path, entry, "servers"), McpFormat::Toml => deploy_mcp_server_toml(config_path, entry), McpFormat::Opencode => deploy_mcp_server_opencode(config_path, entry), + McpFormat::HermesYaml => deploy_mcp_server_hermes_yaml(config_path, entry), } } @@ -290,6 +294,71 @@ fn deploy_mcp_server_opencode(config_path: &Path, entry: &McpServerEntry) -> Res }) } +/// YAML-based MCP deploy for Hermes (`~/.hermes/config.yaml`, "mcp_servers" key). +/// +/// Reads the full config.yaml, upserts the server entry under `mcp_servers.`, +/// and writes the file back. Command-based entries use `command`/`args`/`env` keys; +/// URL-based entries (where `entry.command` starts with "http") use a `url` key. +/// The rest of config.yaml is preserved through serde_yaml round-trip. +fn deploy_mcp_server_hermes_yaml( + config_path: &Path, + entry: &McpServerEntry, +) -> Result<(), HkError> { + let parent = config_path + .parent() + .ok_or_else(|| HkError::Validation("Invalid config path".into()))?; + std::fs::create_dir_all(parent)?; + + let existing = std::fs::read_to_string(config_path).unwrap_or_default(); + let mut doc: serde_yaml::Value = if existing.is_empty() { + serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) + } else { + serde_yaml::from_str(&existing) + .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))? + }; + + let root = doc + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("config.yaml root is not a mapping".into()))?; + + let mcp_key = serde_yaml::Value::String("mcp_servers".into()); + let mcp_servers = root + .entry(mcp_key) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; + + let mut server = serde_yaml::Mapping::new(); + if entry.command.starts_with("http://") || entry.command.starts_with("https://") { + server.insert("url".into(), entry.command.clone().into()); + } else { + server.insert("command".into(), entry.command.clone().into()); + if !entry.args.is_empty() { + let args: Vec = + entry.args.iter().cloned().map(serde_yaml::Value::String).collect(); + server.insert("args".into(), serde_yaml::Value::Sequence(args)); + } + if !entry.env.is_empty() { + let mut env = serde_yaml::Mapping::new(); + for (k, v) in &entry.env { + env.insert(k.clone().into(), v.clone().into()); + } + server.insert("env".into(), serde_yaml::Value::Mapping(env)); + } + } + server.insert("enabled".into(), serde_yaml::Value::Bool(true)); + + mcp_servers.insert( + entry.name.clone().into(), + serde_yaml::Value::Mapping(server), + ); + + let output = + serde_yaml::to_string(&doc).map_err(|e| HkError::Internal(e.to_string()))?; + atomic_write(config_path, &output)?; + Ok(()) +} + /// Build the `serde_json::Value` shape OpenCode's `McpLocalConfig` schema /// expects for one server entry. Shared by `deploy_mcp_server_opencode` /// (cross-agent install path) and intentionally also reachable as the @@ -474,6 +543,21 @@ pub fn remove_mcp_server( Ok(()) } McpFormat::Opencode => remove_mcp_server_opencode(config_path, server_name), + McpFormat::HermesYaml => { + let content = std::fs::read_to_string(config_path)?; + let mut doc: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?; + if let Some(servers) = doc + .get_mut("mcp_servers") + .and_then(|v| v.as_mapping_mut()) + { + servers.remove(server_name); + } + let output = + serde_yaml::to_string(&doc).map_err(|e| HkError::Internal(e.to_string()))?; + atomic_write(config_path, &output)?; + Ok(()) + } _ => locked_modify_json(config_path, |config| { let key = json_top_key(format); if let Some(servers) = config.get_mut(key).and_then(|v| v.as_object_mut()) { @@ -651,6 +735,38 @@ pub fn restore_mcp_server( deploy_mcp_server_toml(config_path, &mcp_entry) } McpFormat::Opencode => restore_mcp_server_opencode(config_path, server_name, entry), + McpFormat::HermesYaml => { + // Reconstruct a McpServerEntry from the saved JSON blob and re-deploy via YAML. + let mcp_entry = McpServerEntry { + name: server_name.to_string(), + command: entry + .get("command") + .and_then(|v| v.as_str()) + .or_else(|| entry.get("url").and_then(|v| v.as_str())) + .unwrap_or("") + .into(), + args: entry + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(), + env: entry + .get("env") + .and_then(|v| v.as_object()) + .map(|obj| { + obj.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(), + enabled: true, + }; + deploy_mcp_server_hermes_yaml(config_path, &mcp_entry) + } _ => { let key = json_top_key(format); locked_modify_json(config_path, |config| { @@ -1132,6 +1248,23 @@ pub fn read_mcp_server_config( } } McpFormat::Opencode => read_mcp_server_config_opencode(config_path, server_name), + McpFormat::HermesYaml => { + let content = std::fs::read_to_string(config_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?; + let Some(entry) = doc + .get("mcp_servers") + .and_then(|v| v.get(server_name)) + else { + return Ok(None); + }; + // Convert to JSON for uniform DB storage; serde_yaml → serde_json via string + let json_str = + serde_json::to_string(&entry).map_err(|e| HkError::Internal(e.to_string()))?; + let json_val: serde_json::Value = + serde_json::from_str(&json_str).map_err(|e| HkError::Internal(e.to_string()))?; + Ok(Some(json_val)) + } _ => { let config = read_or_create_json(config_path)?; let key = json_top_key(format); diff --git a/crates/hk-core/src/kits/install_plan.rs b/crates/hk-core/src/kits/install_plan.rs index 79582ba1..03739fa9 100644 --- a/crates/hk-core/src/kits/install_plan.rs +++ b/crates/hk-core/src/kits/install_plan.rs @@ -51,6 +51,17 @@ fn mcp_entry_exists(config_path: &Path, name: &str, format: McpFormat) -> bool { }; v.get("mcp").and_then(|m| m.get(name)).is_some() } + McpFormat::HermesYaml => { + let Ok(s) = std::fs::read_to_string(config_path) else { + return false; + }; + let Ok(doc) = serde_yaml::from_str::(&s) else { + return false; + }; + doc.get("mcp_servers") + .and_then(|v| v.get(name)) + .is_some() + } } } diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index d0f3f7ed..dd714141 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -1152,6 +1152,7 @@ pub fn install_to_agent( adapters: &[Box], extension_id: &str, target_agent: &str, + hermes_category: Option<&str>, ) -> Result { let (ext, projects) = { let store = store.lock(); @@ -1173,13 +1174,22 @@ pub fn install_to_agent( scanner::find_skill_by_id(adapters, extension_id, &ext.agents, &projects) .map(|loc| loc.entry_path) .ok_or_else(|| HkError::Internal("Could not find source skill files".into()))?; - let target_dir = target_adapter - .skill_dirs() - .into_iter() - .next() - .ok_or_else(|| { + let target_dir = if target_agent == "hermes" { + if let Some(cat) = hermes_category { + target_adapter.base_dir().join("skills").join(cat) + } else { + target_adapter.skill_dirs().into_iter().next().ok_or_else(|| { + HkError::Internal(format!( + "No skill directory for agent '{}'", + target_agent + )) + })? + } + } else { + target_adapter.skill_dirs().into_iter().next().ok_or_else(|| { HkError::Internal(format!("No skill directory for agent '{}'", target_agent)) - })?; + })? + }; let deployed_name = deployer::deploy_skill(&source_path, &target_dir)?; // Propagate install_meta from source to the new target row so @@ -1768,7 +1778,7 @@ mod tests { store.lock().insert_extension(&source_ext).unwrap(); // Cross-agent deploy: claude/foo → codex. - install_to_agent(&store, &adapters, &source_id, "codex").unwrap(); + install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); // File deployed to codex's canonical skill dir (~/.agents/skills), // which is now first in skill_dirs() per Codex's current docs; @@ -1862,7 +1872,7 @@ mod tests { }; store.lock().insert_extension(&source_ext).unwrap(); - install_to_agent(&store, &adapters, &source_id, "codex").unwrap(); + install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); // No install_meta to propagate — target row may not even exist in // the DB yet (we only sync target when there's meta to write). The @@ -1976,7 +1986,7 @@ mod tests { }) .unwrap(); - install_to_agent(&store, &adapters, &source_id, "codex").unwrap(); + install_to_agent(&store, &adapters, &source_id, "codex", None).unwrap(); let target_id = scanner::stable_id_for("baz", "skill", "codex"); let sibling_id = scanner::stable_id_for("baz", "skill", "gemini"); diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index 96ae1e2d..475a7c0f 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -17,12 +17,29 @@ pub enum ScanResult { NoSkills, } +#[tauri::command] +pub async fn list_hermes_categories( + state: State<'_, AppState>, +) -> Result, HkError> { + let adapters = state.adapters.clone(); + tauri::async_runtime::spawn_blocking(move || { + Ok(adapters + .iter() + .find(|a| a.name() == "hermes") + .map(|a| a.list_skill_categories()) + .unwrap_or_default()) + }) + .await + .map_err(|e| HkError::Internal(e.to_string()))? +} + #[tauri::command] pub async fn install_from_local( state: State<'_, AppState>, path: String, target_agents: Vec, target_scope: ConfigScope, + hermes_category: Option, ) -> Result { let store = state.store.clone(); let adapters = state.adapters.clone(); @@ -64,12 +81,32 @@ pub async fn install_from_local( .iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent_name, target_scope - )) - })?; + let target_dir = if agent_name == "hermes" { + if let Some(cat) = &hermes_category { + let hermes_a = adapters.iter().find(|a| a.name() == "hermes").unwrap(); + // Use the hermes adapter to resolve category-specific path + match &target_scope { + ConfigScope::Global => hermes_a.base_dir().join("skills").join(cat), + ConfigScope::Project { path, .. } => { + std::path::Path::new(path).join(".hermes").join("skills").join(cat) + } + } + } else { + a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) + })? + } + } else { + a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, target_scope + )) + })? + }; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; } @@ -595,11 +632,18 @@ pub async fn install_to_agent( state: State<'_, AppState>, extension_id: String, target_agent: String, + hermes_category: Option, ) -> Result { let store = state.store.clone(); let adapters = state.adapters.clone(); tauri::async_runtime::spawn_blocking(move || { - service::install_to_agent(&store, &adapters, &extension_id, &target_agent) + service::install_to_agent( + &store, + &adapters, + &extension_id, + &target_agent, + hermes_category.as_deref(), + ) }) .await .map_err(|e| HkError::Internal(e.to_string()))? diff --git a/crates/hk-desktop/src/commands/marketplace.rs b/crates/hk-desktop/src/commands/marketplace.rs index 64fb8d96..5402ff80 100644 --- a/crates/hk-desktop/src/commands/marketplace.rs +++ b/crates/hk-desktop/src/commands/marketplace.rs @@ -56,6 +56,7 @@ pub async fn install_from_marketplace( skill_id: String, target_agent: Option, target_scope: ConfigScope, + hermes_category: Option, ) -> Result { let store_clone = state.store.clone(); let adapters = state.adapters.clone(); @@ -66,12 +67,30 @@ pub async fn install_from_marketplace( .iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent, target_scope - )) - })?; + let dir = if agent == "hermes" { + if let Some(cat) = &hermes_category { + match &target_scope { + ConfigScope::Global => a.base_dir().join("skills").join(cat), + ConfigScope::Project { path, .. } => { + std::path::PathBuf::from(path).join(".hermes").join("skills").join(cat) + } + } + } else { + a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, target_scope + )) + })? + } + } else { + a.skill_dir_for(&target_scope).ok_or_else(|| { + HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, target_scope + )) + })? + }; (dir, agent.clone()) } else { let a = adapters diff --git a/crates/hk-desktop/src/main.rs b/crates/hk-desktop/src/main.rs index 74229759..fabdaefb 100644 --- a/crates/hk-desktop/src/main.rs +++ b/crates/hk-desktop/src/main.rs @@ -48,6 +48,7 @@ fn main() { commands::check_updates, commands::update_extension, commands::install_from_local, + commands::list_hermes_categories, commands::install_from_git, commands::update_tags, commands::get_all_tags, diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index 9b4423e5..13464746 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -86,6 +86,7 @@ pub struct InstallFromMarketplaceParams { pub skill_id: String, pub target_agent: Option, pub target_scope: ConfigScope, + pub hermes_category: Option, } pub async fn install_from_marketplace( @@ -98,12 +99,30 @@ pub async fn install_from_marketplace( let a = state.adapters.iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| hk_core::HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { - hk_core::HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent, params.target_scope - )) - })?; + let dir = if agent == "hermes" { + if let Some(cat) = ¶ms.hermes_category { + match ¶ms.target_scope { + ConfigScope::Global => a.base_dir().join("skills").join(cat), + ConfigScope::Project { path, .. } => { + std::path::Path::new(path).join(".hermes").join("skills").join(cat) + } + } + } else { + a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, params.target_scope + )) + })? + } + } else { + a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent, params.target_scope + )) + })? + }; (dir, agent.clone()) } else { let a = state.adapters.iter().find(|a| a.detect()) @@ -152,6 +171,7 @@ pub struct InstallFromLocalParams { pub path: String, pub target_agents: Vec, pub target_scope: ConfigScope, + pub hermes_category: Option, } pub async fn install_from_local( @@ -185,12 +205,30 @@ pub async fn install_from_local( let a = state.adapters.iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { - hk_core::HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent_name, params.target_scope - )) - })?; + let target_dir = if agent_name == "hermes" { + if let Some(cat) = ¶ms.hermes_category { + match ¶ms.target_scope { + ConfigScope::Global => a.base_dir().join("skills").join(cat), + ConfigScope::Project { path, .. } => { + std::path::Path::new(path).join(".hermes").join("skills").join(cat) + } + } + } else { + a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) + })? + } + } else { + a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + hk_core::HkError::Internal(format!( + "Agent '{}' has no skill directory for scope {:?}", + agent_name, params.target_scope + )) + })? + }; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; } @@ -233,6 +271,7 @@ pub async fn install_from_local( pub struct InstallToAgentParams { pub extension_id: String, pub target_agent: String, + pub hermes_category: Option, } pub async fn install_to_agent( @@ -262,6 +301,7 @@ pub async fn install_to_agent( &state.adapters, ¶ms.extension_id, ¶ms.target_agent, + params.hermes_category.as_deref(), )?; // Web-only: re-scan + sync after a successful deploy so the new @@ -892,3 +932,18 @@ pub async fn get_skill_locations( Ok(result) }).await } + +pub async fn list_hermes_categories( + State(state): State, + Json(_): Json, +) -> Result> { + blocking(move || { + Ok(state + .adapters + .iter() + .find(|a| a.name() == "hermes") + .map(|a| a.list_skill_categories()) + .unwrap_or_default()) + }) + .await +} diff --git a/crates/hk-web/src/router.rs b/crates/hk-web/src/router.rs index 79b735d5..d6c03e34 100644 --- a/crates/hk-web/src/router.rs +++ b/crates/hk-web/src/router.rs @@ -123,6 +123,7 @@ pub fn build_router(state: WebState) -> Router { .route("/api/install_from_git", post(handlers::install::install_from_git)) .route("/api/install_from_marketplace", post(handlers::install::install_from_marketplace)) .route("/api/install_from_local", post(handlers::install::install_from_local)) + .route("/api/list_hermes_categories", post(handlers::install::list_hermes_categories)) .route("/api/install_to_agent", post(handlers::install::install_to_agent)) .route("/api/update_extension", post(handlers::install::update_extension)) .route("/api/check_updates", post(handlers::install::check_updates)) diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 61cd2288..4508cc4e 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -74,6 +74,12 @@ export function ExtensionDetail() { ); const projectScopeBlocked = !globalSourceInstance; const [deploying, setDeploying] = useState(null); + // Hermes cross-agent deploy: show category picker before confirming install + const [hermesCategoryPicker, setHermesCategoryPicker] = useState(false); + const [hermesCategories, setHermesCategories] = useState([]); + const [hermesDeployCategory, setHermesDeployCategory] = useState("local"); + const [hermesNewCategory, setHermesNewCategory] = useState(""); + const [hermesNewCategoryMode, setHermesNewCategoryMode] = useState(false); const [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); const [deleteAgents, setDeleteAgents] = useState>(new Set()); @@ -130,6 +136,34 @@ export function ExtensionDetail() { setDeleteAgents(new Set()); }, [group?.groupKey]); + // Load content + skill locations for any instances added after the initial load + // (e.g. after a successful cross-agent install without navigating away). + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — only fire when instance count changes, not on every group rebuild + useEffect(() => { + if (!group) return; + const unloaded = group.instances.filter((i) => !instanceData.has(i.id)); + if (unloaded.length === 0) return; + Promise.all( + unloaded.map((inst) => + api + .getExtensionContent(inst.id) + .then((res) => [inst.id, res] as const) + .catch(() => [inst.id, null] as const), + ), + ).then((results) => { + setInstanceData((prev) => { + const updated = new Map(prev); + for (const [id, data] of results) { + if (data) updated.set(id, data); + } + return updated; + }); + }); + if (group.kind === "skill") { + api.getSkillLocations(group.name).then(setSkillLocations).catch(() => {}); + } + }, [group?.instances.length]); + // Reset deleteAgents when showDelete is toggled on useEffect(() => { if (showDelete && group) { @@ -479,6 +513,7 @@ export function ExtensionDetail() { const hookUnsupported = group.kind === "hook" && AGENTS_WITHOUT_HOOKS.has(agent.name); + const isHermes = agent.name === "hermes" && group.kind === "skill"; return ( + + ) : ( +
+ {hermesCategories.map((cat) => ( + + ))} + +
+ )} +
+ + +
+ + )} ); })()} diff --git a/src/components/extensions/install-dialog.tsx b/src/components/extensions/install-dialog.tsx index 001410c2..aaa0ff35 100644 --- a/src/components/extensions/install-dialog.tsx +++ b/src/components/extensions/install-dialog.tsx @@ -38,6 +38,10 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { ); const [selectedSkills, setSelectedSkills] = useState>(new Set()); const [cloneId, setCloneId] = useState(null); + const [hermesCategories, setHermesCategories] = useState([]); + const [hermesCategory, setHermesCategory] = useState("local"); + const [newCategoryInput, setNewCategoryInput] = useState(""); + const [showNewCategory, setShowNewCategory] = useState(false); const fetch = useExtensionStore((s) => s.fetch); const { agents, fetch: fetchAgents, agentOrder } = useAgentStore(); const { scope } = useScope(); @@ -68,6 +72,20 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { } }, [singleAgentName]); + const hermesSelected = selectedAgents.has("hermes"); + + // Fetch Hermes categories when Hermes is a selected target + useEffect(() => { + if (!hermesSelected) return; + api.listHermesCategories().then((cats) => { + setHermesCategories(cats); + if (!cats.includes(hermesCategory)) { + setHermesCategory(cats[0] ?? "local"); + } + }).catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [hermesSelected]); + // Reset form when closing useEffect(() => { if (!open) { @@ -77,6 +95,9 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setDiscoveredSkills([]); setSelectedSkills(new Set()); setCloneId(null); + setHermesCategory("local"); + setNewCategoryInput(""); + setShowNewCategory(false); setInstallTargetScope( scope.type === "all" ? null : (scope as ConfigScope), ); @@ -148,11 +169,15 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setLoading(true); setError(null); try { + const effectiveHermesCategory = showNewCategory + ? newCategoryInput.trim() || "local" + : hermesCategory; if (mode === "local") { const result = await api.installFromLocal( source.trim(), [...selectedAgents], installTargetScope, + hermesSelected ? effectiveHermesCategory : undefined, ); await fetch(); onClose(); @@ -330,6 +355,62 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { onChange={setInstallTargetScope} /> + + {/* Hermes category picker — only when Hermes is a selected target */} + {hermesSelected && ( +
+ + Hermes category + + {showNewCategory ? ( +
+ setNewCategoryInput(e.target.value)} + placeholder="new-category-name" + className="flex-1 rounded-lg border border-border bg-muted px-3 py-1.5 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" + disabled={loading} + autoFocus + /> + +
+ ) : ( +
+ {hermesCategories.map((cat) => ( + + ))} + +
+ )} +
+ )} ) : ( <> diff --git a/src/components/onboarding/onboarding.tsx b/src/components/onboarding/onboarding.tsx index 0c522d06..1d165e06 100644 --- a/src/components/onboarding/onboarding.tsx +++ b/src/components/onboarding/onboarding.tsx @@ -263,6 +263,7 @@ const FLOAT_DELAYS: Record<(typeof AGENT_ORDER)[number], number> = { copilot: 1.1, windsurf: 1.6, opencode: 0.8, + hermes: 1.9, }; const SCATTER_POSITIONS: Record< (typeof AGENT_ORDER)[number], @@ -276,6 +277,7 @@ const SCATTER_POSITIONS: Record< copilot: { x: 150, y: 80, r: 15 }, windsurf: { x: 0, y: 108, r: -6 }, opencode: { x: 210, y: -10, r: 8 }, + hermes: { x: -60, y: -115, r: 18 }, }; function HandAnnotation({ diff --git a/src/components/shared/agent-mascot/agent-mascot.tsx b/src/components/shared/agent-mascot/agent-mascot.tsx index 55000a5e..b208a2f2 100644 --- a/src/components/shared/agent-mascot/agent-mascot.tsx +++ b/src/components/shared/agent-mascot/agent-mascot.tsx @@ -6,6 +6,7 @@ import { CopilotMascot } from "./copilot-mascot"; import { CursorMascot } from "./cursor-mascot"; import { FallbackMascot } from "./fallback-mascot"; import { GeminiMascot } from "./gemini-mascot"; +import { HermesMascot } from "./hermes-mascot"; import { OpencodeMascot } from "./opencode-mascot"; import { WindsurfMascot } from "./windsurf-mascot"; @@ -57,6 +58,11 @@ const MASCOT_MAP: Record< className: "mascot-opencode", scale: 0.92, }, + hermes: { + component: HermesMascot, + className: "mascot-hermes", + scale: 1, + }, }; export function AgentMascot({ diff --git a/src/components/shared/agent-mascot/hermes-mascot.tsx b/src/components/shared/agent-mascot/hermes-mascot.tsx new file mode 100644 index 00000000..40352a8d --- /dev/null +++ b/src/components/shared/agent-mascot/hermes-mascot.tsx @@ -0,0 +1,23 @@ +interface MascotSvgProps { + size: number; +} + +export function HermesMascot({ size }: MascotSvgProps) { + return ( + // currentColor inherits from CSS `color`, which we set to the themed icon colour. + + + + + + ); +} diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css index 25996b3a..b5a72b8a 100644 --- a/src/components/shared/agent-mascot/mascot.css +++ b/src/components/shared/agent-mascot/mascot.css @@ -1271,6 +1271,38 @@ 100% { opacity: 0; transform: translate(0, 0) rotate(0deg); } } +/* === Hermes === */ +.mascot-hermes.is-animated .hermes-svg { + animation: hermes-float 2.2s ease-in-out infinite; +} +.mascot-hermes.is-clicked .hermes-svg { + animation: hermes-spin 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +@keyframes hermes-float { + 0%, + 100% { + transform: translateY(0) scale(1); + } + 40% { + transform: translateY(-5px) scale(1.04); + } + 70% { + transform: translateY(-3px) scale(1.02); + } +} +@keyframes hermes-spin { + 0% { + transform: rotate(0deg) scale(1); + } + 40% { + transform: rotate(200deg) scale(1.12); + } + 100% { + transform: rotate(360deg) scale(1); + } +} + /* === Fallback === */ .mascot-fallback.is-animated .fallback-icon { animation: fallback-pulse 2s ease-in-out infinite; @@ -1307,6 +1339,7 @@ .mascot-copilot.is-animated *, .mascot-windsurf.is-animated *, .mascot-opencode.is-animated *, + .mascot-hermes.is-animated *, .mascot-fallback.is-animated *, .mascot-claude.is-clicked, .mascot-claude.is-clicked *, @@ -1322,6 +1355,8 @@ .mascot-windsurf.is-clicked *, .mascot-opencode.is-clicked, .mascot-opencode.is-clicked *, + .mascot-hermes.is-clicked, + .mascot-hermes.is-clicked *, .mascot-fallback.is-clicked * { animation: none; transition: none; diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 5d72684d..7651c87d 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -110,8 +110,18 @@ export const api = { path: string, targetAgents: string[], targetScope: ConfigScope, + hermesCategory?: string, ): Promise { - return transport("install_from_local", { path, targetAgents, targetScope }); + return transport("install_from_local", { + path, + targetAgents, + targetScope, + hermesCategory: hermesCategory ?? null, + }); + }, + + listHermesCategories(): Promise { + return transport("list_hermes_categories"); }, installFromGit( @@ -236,17 +246,27 @@ export const api = { skillId: string, targetAgent: string | undefined, targetScope: ConfigScope, + hermesCategory?: string, ): Promise { return transport("install_from_marketplace", { source, skillId, targetAgent, targetScope, + hermesCategory: hermesCategory ?? null, }); }, - installToAgent(extensionId: string, targetAgent: string): Promise { - return transport("install_to_agent", { extensionId, targetAgent }); + installToAgent( + extensionId: string, + targetAgent: string, + hermesCategory?: string, + ): Promise { + return transport("install_to_agent", { + extensionId, + targetAgent, + hermesCategory: hermesCategory ?? null, + }); }, listProjects(): Promise { diff --git a/src/lib/types.ts b/src/lib/types.ts index 38803f2d..c253ed8a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -375,6 +375,7 @@ export const AGENT_ORDER = [ "copilot", "windsurf", "opencode", + "hermes", ] as const; /** Sort an array of agents (or agent-like objects with a `name` field) by a given order. */ @@ -398,6 +399,7 @@ const AGENT_DISPLAY_NAMES: Record = { copilot: "Copilot", windsurf: "Windsurf", opencode: "OpenCode", + hermes: "Hermes", }; /** Get the display name for an agent (e.g. "claude" → "Claude Code"). */ diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index 9b70a648..e62dba20 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -26,6 +26,7 @@ import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useScope } from "@/hooks/use-scope"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; import { canInstallAtScope } from "@/lib/agent-capabilities"; +import { api } from "@/lib/invoke"; import { humanizeError } from "@/lib/errors"; import { agentDisplayName, @@ -251,6 +252,12 @@ export default function MarketplacePage() { const [error, setError] = useState(null); const [showInstall, setShowInstall] = useState(false); const [installMode, setInstallMode] = useState<"git" | "local">("git"); + // Hermes category picker state (marketplace install) + const [hermesPending, setHermesPending] = useState<{ item: MarketplaceItem; scope: ConfigScope } | null>(null); + const [hermesMarketCategories, setHermesMarketCategories] = useState([]); + const [hermesMarketCategory, setHermesMarketCategory] = useState("local"); + const [hermesMarketNewMode, setHermesMarketNewMode] = useState(false); + const [hermesMarketNewName, setHermesMarketNewName] = useState(""); const detailPanelRef = useRef(null); const isItemInstalled = ( @@ -347,10 +354,21 @@ export default function MarketplacePage() { item: MarketplaceItem, targetAgent: string | undefined, targetScope: ConfigScope, + hermesCategory?: string, ) => { + // For Hermes skill installs, show category picker first (unless category already provided) + if (targetAgent === "hermes" && item.kind === "skill" && !hermesCategory) { + const cats = await api.listHermesCategories().catch(() => []); + setHermesMarketCategories(cats); + setHermesMarketCategory(cats[0] ?? "local"); + setHermesMarketNewMode(false); + setHermesMarketNewName(""); + setHermesPending({ item, scope: targetScope }); + return; + } setError(null); try { - const result = await install(item, targetAgent, targetScope); + const result = await install(item, targetAgent, targetScope, hermesCategory); // Refresh extension store so audit page can resolve names immediately useExtensionStore.getState().fetch(); const key = `${item.id}:${targetAgent ?? ""}`; @@ -855,6 +873,70 @@ export default function MarketplacePage() { )} + {/* Hermes category picker — shown when Hermes is clicked */} + {hermesPending && hermesPending.item.id === selectedItem.id && ( +
+

+ Choose a Hermes category +

+ {hermesMarketNewMode ? ( +
+ setHermesMarketNewName(e.target.value)} + placeholder="new-category-name" + className="flex-1 rounded-lg border border-border bg-background px-2.5 py-1 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" + autoFocus + /> + +
+ ) : ( +
+ {hermesMarketCategories.map((cat) => ( + + ))} + +
+ )} +
+ + +
+
+ )} + {/* SKILL.md content (skills only) */} {selectedItem.kind === "skill" && (
diff --git a/src/stores/extension-store.ts b/src/stores/extension-store.ts index 46c3697b..cda5a27c 100644 --- a/src/stores/extension-store.ts +++ b/src/stores/extension-store.ts @@ -64,7 +64,7 @@ interface ExtensionState { updateTags: (groupKey: string, tags: string[]) => Promise; updatePack: (groupKey: string, pack: string | null) => Promise; fetchPacks: () => Promise; - installToAgent: (id: string, targetAgent: string) => Promise; + installToAgent: (id: string, targetAgent: string, hermesCategory?: string) => Promise; toggle: (groupKey: string, enabled: boolean) => Promise; batchToggle: (enabled: boolean) => Promise; undoDelete: () => void; @@ -261,8 +261,8 @@ export const useExtensionStore = create((set, get) => ({ }); }, - async installToAgent(id, targetAgent) { - await api.installToAgent(id, targetAgent); + async installToAgent(id, targetAgent, hermesCategory) { + await api.installToAgent(id, targetAgent, hermesCategory); await get().rescanAndFetch(); }, diff --git a/src/stores/marketplace-store.ts b/src/stores/marketplace-store.ts index ced56000..baa4e7d6 100644 --- a/src/stores/marketplace-store.ts +++ b/src/stores/marketplace-store.ts @@ -44,6 +44,7 @@ interface MarketplaceState { item: MarketplaceItem, targetAgent: string | undefined, targetScope: ConfigScope, + hermesCategory?: string, ) => Promise; } @@ -437,7 +438,7 @@ export const useMarketplaceStore = create((set, get) => ({ cliReadme: null, }); }, - async install(item, targetAgent, targetScope) { + async install(item, targetAgent, targetScope, hermesCategory) { set({ installing: `${item.id}:${targetAgent ?? ""}` }); try { let { source, skill_id } = item; @@ -464,6 +465,7 @@ export const useMarketplaceStore = create((set, get) => ({ skill_id, targetAgent, targetScope, + hermesCategory, ); set({ installing: null }); return result; From f8abc45a931339adac54da155ca8776da31d7b96 Mon Sep 17 00:00:00 2001 From: RealZST Date: Wed, 3 Jun 2026 23:28:18 +0300 Subject: [PATCH 02/12] =?UTF-8?q?refactor(hermes):=20clean=20up=20PR=20#74?= =?UTF-8?q?=20=E2=80=94=20dedup=20category=20picker=20into=20shared=20comp?= =?UTF-8?q?onent,=20remove=20dead=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the duplicated 'tag buttons + new-input' UI (install dialog, detail page, marketplace) into a single shared HermesCategoryPicker. Remove dead code and backend/frontend duplication left by the PR. Baseline for the full Hermes support work (hooks/plugins/MCP/skills). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/adapter/hermes.rs | 65 +++++++++----- crates/hk-core/src/adapter/mod.rs | 32 +++++++ crates/hk-core/src/service.rs | 25 +++--- crates/hk-desktop/src/commands/install.rs | 30 ++----- crates/hk-desktop/src/commands/marketplace.rs | 26 ++---- crates/hk-web/src/handlers/install.rs | 54 +++--------- .../extensions/extension-detail.tsx | 76 +++++----------- src/components/extensions/install-dialog.tsx | 83 +++++------------- .../shared/agent-mascot/hermes-mascot.tsx | 5 +- .../shared/hermes-category-picker.tsx | 87 +++++++++++++++++++ src/pages/marketplace.tsx | 86 ++++++++---------- src/stores/extension-store.ts | 6 +- 12 files changed, 291 insertions(+), 284 deletions(-) create mode 100644 src/components/shared/hermes-category-picker.tsx diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index db5f705d..1b4589f7 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -57,27 +57,6 @@ impl HermesAdapter { cats } - /// Resolve the target skill directory for a given scope and optional category override. - /// When `category` is provided, use `~/.hermes/skills/{category}` (global) or - /// `.hermes/skills/{category}` (project) instead of the default "local" category. - pub fn skill_dir_for_category( - &self, - scope: &crate::models::ConfigScope, - category: &str, - ) -> std::path::PathBuf { - match scope { - crate::models::ConfigScope::Global => { - self.base_dir().join("skills").join(category) - } - crate::models::ConfigScope::Project { path, .. } => { - std::path::Path::new(path) - .join(".hermes") - .join("skills") - .join(category) - } - } - } - fn parse_yaml(path: &Path) -> Option { let content = std::fs::read_to_string(path).ok()?; serde_yaml::from_str(&content).ok() @@ -249,6 +228,24 @@ impl AgentAdapter for HermesAdapter { self.list_categories() } + /// Hermes organises skills into named category subdirectories. Resolve the + /// install target as `~/.hermes/skills/{category}` (global) or + /// `/.hermes/skills/{category}` (project). + fn skill_dir_for_category( + &self, + scope: &crate::models::ConfigScope, + category: &str, + ) -> Option { + Some(match scope { + crate::models::ConfigScope::Global => { + self.base_dir().join("skills").join(category) + } + crate::models::ConfigScope::Project { path, .. } => { + Path::new(path).join(".hermes").join("skills").join(category) + } + }) + } + // --- Config file discovery (Agents page) --- fn global_rules_files(&self) -> Vec { @@ -400,4 +397,30 @@ mod tests { let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); assert!(adapter.read_mcp_servers().is_empty()); } + + #[test] + fn test_skill_dir_for_category_resolves_global_and_project() { + use crate::models::ConfigScope; + let tmp = tempfile::tempdir().unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + + let global = adapter + .skill_dir_for_category(&ConfigScope::Global, "devops") + .expect("hermes resolves a global category dir"); + assert!(global.ends_with(".hermes/skills/devops")); + + let project = adapter + .skill_dir_for_category( + &ConfigScope::Project { + name: "demo".into(), + path: "/tmp/proj".into(), + }, + "apple", + ) + .expect("hermes resolves a project category dir"); + assert_eq!( + project, + std::path::Path::new("/tmp/proj/.hermes/skills/apple") + ); + } } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index e59a1564..8903b1a4 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -357,6 +357,18 @@ pub trait AgentAdapter: Send + Sync { .map(|rel| std::path::Path::new(path).join(rel)), } } + + /// Resolve a category-specific skill directory for agents that organise + /// skills into named subdirectories (e.g. Hermes: `~/.hermes/skills/{category}/`). + /// Returns `None` for agents with a flat skill layout, letting callers fall + /// back to `skill_dir_for(scope)`. + fn skill_dir_for_category( + &self, + _scope: &ConfigScope, + _category: &str, + ) -> Option { + None + } } fn pick_unique_concrete(patterns: Vec) -> Option { @@ -431,6 +443,26 @@ mod tests { } } + #[test] + fn test_skill_dir_for_category_default_is_none_except_hermes() { + // The install handlers rely on this contract: only category-aware + // agents resolve a dir here; everyone else returns None and falls + // back to skill_dir_for(). Hermes is the sole override today. + let adapters = all_adapters(); + for a in &adapters { + let resolved = a.skill_dir_for_category(&ConfigScope::Global, "devops"); + if a.name() == "hermes" { + assert!(resolved.is_some(), "hermes should resolve a category dir"); + } else { + assert!( + resolved.is_none(), + "{} should not resolve a category dir", + a.name() + ); + } + } + } + #[test] fn test_default_config_methods_return_empty() { let adapters = all_adapters(); diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index dd714141..88d494b9 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -1174,22 +1174,17 @@ pub fn install_to_agent( scanner::find_skill_by_id(adapters, extension_id, &ext.agents, &projects) .map(|loc| loc.entry_path) .ok_or_else(|| HkError::Internal("Could not find source skill files".into()))?; - let target_dir = if target_agent == "hermes" { - if let Some(cat) = hermes_category { - target_adapter.base_dir().join("skills").join(cat) - } else { - target_adapter.skill_dirs().into_iter().next().ok_or_else(|| { - HkError::Internal(format!( - "No skill directory for agent '{}'", - target_agent - )) - })? - } - } else { - target_adapter.skill_dirs().into_iter().next().ok_or_else(|| { + // Cross-agent install always lands at the target's global scope. + // `skill_dir_for_category` returns None for flat-layout agents, so + // non-Hermes targets fall through to their default skill dir. + let target_dir = hermes_category + .and_then(|cat| { + target_adapter.skill_dir_for_category(&ConfigScope::Global, cat) + }) + .or_else(|| target_adapter.skill_dirs().into_iter().next()) + .ok_or_else(|| { HkError::Internal(format!("No skill directory for agent '{}'", target_agent)) - })? - }; + })?; let deployed_name = deployer::deploy_skill(&source_path, &target_dir)?; // Propagate install_meta from source to the new target row so diff --git a/crates/hk-desktop/src/commands/install.rs b/crates/hk-desktop/src/commands/install.rs index 475a7c0f..a4664ce4 100644 --- a/crates/hk-desktop/src/commands/install.rs +++ b/crates/hk-desktop/src/commands/install.rs @@ -81,32 +81,18 @@ pub async fn install_from_local( .iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = if agent_name == "hermes" { - if let Some(cat) = &hermes_category { - let hermes_a = adapters.iter().find(|a| a.name() == "hermes").unwrap(); - // Use the hermes adapter to resolve category-specific path - match &target_scope { - ConfigScope::Global => hermes_a.base_dir().join("skills").join(cat), - ConfigScope::Project { path, .. } => { - std::path::Path::new(path).join(".hermes").join("skills").join(cat) - } - } - } else { - a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent_name, target_scope - )) - })? - } - } else { - a.skill_dir_for(&target_scope).ok_or_else(|| { + // A category only resolves a dir for category-aware agents (Hermes); + // everything else returns None here and falls back to skill_dir_for. + let target_dir = hermes_category + .as_deref() + .and_then(|cat| a.skill_dir_for_category(&target_scope, cat)) + .or_else(|| a.skill_dir_for(&target_scope)) + .ok_or_else(|| { HkError::Internal(format!( "Agent '{}' has no skill directory for scope {:?}", agent_name, target_scope )) - })? - }; + })?; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; } diff --git a/crates/hk-desktop/src/commands/marketplace.rs b/crates/hk-desktop/src/commands/marketplace.rs index 5402ff80..56096978 100644 --- a/crates/hk-desktop/src/commands/marketplace.rs +++ b/crates/hk-desktop/src/commands/marketplace.rs @@ -67,30 +67,16 @@ pub async fn install_from_marketplace( .iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = if agent == "hermes" { - if let Some(cat) = &hermes_category { - match &target_scope { - ConfigScope::Global => a.base_dir().join("skills").join(cat), - ConfigScope::Project { path, .. } => { - std::path::PathBuf::from(path).join(".hermes").join("skills").join(cat) - } - } - } else { - a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent, target_scope - )) - })? - } - } else { - a.skill_dir_for(&target_scope).ok_or_else(|| { + let dir = hermes_category + .as_deref() + .and_then(|cat| a.skill_dir_for_category(&target_scope, cat)) + .or_else(|| a.skill_dir_for(&target_scope)) + .ok_or_else(|| { HkError::Internal(format!( "Agent '{}' has no skill directory for scope {:?}", agent, target_scope )) - })? - }; + })?; (dir, agent.clone()) } else { let a = adapters diff --git a/crates/hk-web/src/handlers/install.rs b/crates/hk-web/src/handlers/install.rs index 13464746..6ffe1075 100644 --- a/crates/hk-web/src/handlers/install.rs +++ b/crates/hk-web/src/handlers/install.rs @@ -99,30 +99,17 @@ pub async fn install_from_marketplace( let a = state.adapters.iter() .find(|a| a.name() == agent.as_str()) .ok_or_else(|| hk_core::HkError::Internal(format!("Agent '{}' not found", agent)))?; - let dir = if agent == "hermes" { - if let Some(cat) = ¶ms.hermes_category { - match ¶ms.target_scope { - ConfigScope::Global => a.base_dir().join("skills").join(cat), - ConfigScope::Project { path, .. } => { - std::path::Path::new(path).join(".hermes").join("skills").join(cat) - } - } - } else { - a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { - hk_core::HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent, params.target_scope - )) - })? - } - } else { - a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + let dir = params + .hermes_category + .as_deref() + .and_then(|cat| a.skill_dir_for_category(¶ms.target_scope, cat)) + .or_else(|| a.skill_dir_for(¶ms.target_scope)) + .ok_or_else(|| { hk_core::HkError::Internal(format!( "Agent '{}' has no skill directory for scope {:?}", agent, params.target_scope )) - })? - }; + })?; (dir, agent.clone()) } else { let a = state.adapters.iter().find(|a| a.detect()) @@ -205,30 +192,17 @@ pub async fn install_from_local( let a = state.adapters.iter() .find(|a| a.name() == agent_name.as_str()) .ok_or_else(|| hk_core::HkError::NotFound(format!("Agent '{}' not found", agent_name)))?; - let target_dir = if agent_name == "hermes" { - if let Some(cat) = ¶ms.hermes_category { - match ¶ms.target_scope { - ConfigScope::Global => a.base_dir().join("skills").join(cat), - ConfigScope::Project { path, .. } => { - std::path::Path::new(path).join(".hermes").join("skills").join(cat) - } - } - } else { - a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { - hk_core::HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent_name, params.target_scope - )) - })? - } - } else { - a.skill_dir_for(¶ms.target_scope).ok_or_else(|| { + let target_dir = params + .hermes_category + .as_deref() + .and_then(|cat| a.skill_dir_for_category(¶ms.target_scope, cat)) + .or_else(|| a.skill_dir_for(¶ms.target_scope)) + .ok_or_else(|| { hk_core::HkError::Internal(format!( "Agent '{}' has no skill directory for scope {:?}", agent_name, params.target_scope )) - })? - }; + })?; std::fs::create_dir_all(&target_dir)?; deployer::deploy_skill(source_path, &target_dir)?; } diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 4508cc4e..9a9fb63f 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -18,6 +18,7 @@ import { DetailHeader } from "@/components/extensions/detail-header"; import { DetailPaths } from "@/components/extensions/detail-paths"; import { PermissionDetail } from "@/components/extensions/permission-detail"; import { SkillFileSection } from "@/components/extensions/skill-file-section"; +import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; import i18n from "@/lib/i18n"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; @@ -78,8 +79,6 @@ export function ExtensionDetail() { const [hermesCategoryPicker, setHermesCategoryPicker] = useState(false); const [hermesCategories, setHermesCategories] = useState([]); const [hermesDeployCategory, setHermesDeployCategory] = useState("local"); - const [hermesNewCategory, setHermesNewCategory] = useState(""); - const [hermesNewCategoryMode, setHermesNewCategoryMode] = useState(false); const [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); const [deleteAgents, setDeleteAgents] = useState>(new Set()); @@ -160,7 +159,10 @@ export function ExtensionDetail() { }); }); if (group.kind === "skill") { - api.getSkillLocations(group.name).then(setSkillLocations).catch(() => {}); + api + .getSkillLocations(group.name) + .then(setSkillLocations) + .catch(() => {}); } }, [group?.instances.length]); @@ -513,7 +515,8 @@ export function ExtensionDetail() { const hookUnsupported = group.kind === "hook" && AGENTS_WITHOUT_HOOKS.has(agent.name); - const isHermes = agent.name === "hermes" && group.kind === "skill"; + const isHermes = + agent.name === "hermes" && group.kind === "skill"; return ( -
- ) : ( -
- {hermesCategories.map((cat) => ( - - ))} - -
- )} +
diff --git a/src/components/extensions/install-dialog.tsx b/src/components/extensions/install-dialog.tsx index aaa0ff35..4134049b 100644 --- a/src/components/extensions/install-dialog.tsx +++ b/src/components/extensions/install-dialog.tsx @@ -2,6 +2,7 @@ import { ChevronLeft, FolderSearch } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AnimatedEllipsis } from "@/components/shared/animated-ellipsis"; +import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useScope } from "@/hooks/use-scope"; @@ -40,8 +41,6 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { const [cloneId, setCloneId] = useState(null); const [hermesCategories, setHermesCategories] = useState([]); const [hermesCategory, setHermesCategory] = useState("local"); - const [newCategoryInput, setNewCategoryInput] = useState(""); - const [showNewCategory, setShowNewCategory] = useState(false); const fetch = useExtensionStore((s) => s.fetch); const { agents, fetch: fetchAgents, agentOrder } = useAgentStore(); const { scope } = useScope(); @@ -74,16 +73,19 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { const hermesSelected = selectedAgents.has("hermes"); - // Fetch Hermes categories when Hermes is a selected target + // Fetch Hermes categories when Hermes is a selected target. + // biome-ignore lint/correctness/useExhaustiveDependencies: fetch once when Hermes is selected; reading hermesCategory only to keep a still-valid pick, so it must not be a dependency (would refetch on every category change). useEffect(() => { if (!hermesSelected) return; - api.listHermesCategories().then((cats) => { - setHermesCategories(cats); - if (!cats.includes(hermesCategory)) { - setHermesCategory(cats[0] ?? "local"); - } - }).catch(() => {}); - // eslint-disable-next-line react-hooks/exhaustive-deps + api + .listHermesCategories() + .then((cats) => { + setHermesCategories(cats); + if (!cats.includes(hermesCategory)) { + setHermesCategory(cats[0] ?? "local"); + } + }) + .catch(() => {}); }, [hermesSelected]); // Reset form when closing @@ -96,8 +98,6 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setSelectedSkills(new Set()); setCloneId(null); setHermesCategory("local"); - setNewCategoryInput(""); - setShowNewCategory(false); setInstallTargetScope( scope.type === "all" ? null : (scope as ConfigScope), ); @@ -169,9 +169,7 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setLoading(true); setError(null); try { - const effectiveHermesCategory = showNewCategory - ? newCategoryInput.trim() || "local" - : hermesCategory; + const effectiveHermesCategory = hermesCategory.trim() || "local"; if (mode === "local") { const result = await api.installFromLocal( source.trim(), @@ -362,53 +360,14 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { Hermes category - {showNewCategory ? ( -
- setNewCategoryInput(e.target.value)} - placeholder="new-category-name" - className="flex-1 rounded-lg border border-border bg-muted px-3 py-1.5 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" - disabled={loading} - autoFocus - /> - -
- ) : ( -
- {hermesCategories.map((cat) => ( - - ))} - -
- )} +
+ +
)} diff --git a/src/components/shared/agent-mascot/hermes-mascot.tsx b/src/components/shared/agent-mascot/hermes-mascot.tsx index 40352a8d..6a163764 100644 --- a/src/components/shared/agent-mascot/hermes-mascot.tsx +++ b/src/components/shared/agent-mascot/hermes-mascot.tsx @@ -17,7 +17,10 @@ export function HermesMascot({ size }: MascotSvgProps) { > - + ); } diff --git a/src/components/shared/hermes-category-picker.tsx b/src/components/shared/hermes-category-picker.tsx new file mode 100644 index 00000000..e3b03a99 --- /dev/null +++ b/src/components/shared/hermes-category-picker.tsx @@ -0,0 +1,87 @@ +import { useState } from "react"; + +interface HermesCategoryPickerProps { + /** Existing category names under `~/.hermes/skills/`. */ + categories: string[]; + /** Currently selected category, or the in-progress name when adding a new one. */ + value: string; + /** Reports the effective category (a picked name, or the typed new name). */ + onChange: (category: string) => void; + disabled?: boolean; +} + +/** + * Category selector for Hermes skill installs — a pill row of existing + * categories plus a "+ New" affordance that swaps in a free-text input. + * + * Self-contained: the new-vs-pick mode lives here and the parent only tracks the + * resulting category string via `onChange`. Callers should still coerce an empty + * value to a sensible default (e.g. `value.trim() || "local"`) at submit time. + */ +export function HermesCategoryPicker({ + categories, + value, + onChange, + disabled, +}: HermesCategoryPickerProps) { + const [newMode, setNewMode] = useState(false); + + if (newMode) { + return ( +
+ onChange(e.target.value)} + placeholder="new-category-name" + className="flex-1 rounded-lg border border-border bg-background px-2.5 py-1 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" + disabled={disabled} + // biome-ignore lint/a11y/noAutofocus: new-category mode is opt-in (user clicked "+ New"); focusing the input they just summoned is the expected behavior, not a surprise focus trap. + autoFocus + /> + +
+ ); + } + + return ( +
+ {categories.map((cat) => ( + + ))} + +
+ ); +} diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index e62dba20..e26b0743 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -21,13 +21,14 @@ import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { InstallDialog } from "@/components/extensions/install-dialog"; import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; +import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker"; import { Hint } from "@/components/shared/hint"; import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useScope } from "@/hooks/use-scope"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; import { canInstallAtScope } from "@/lib/agent-capabilities"; -import { api } from "@/lib/invoke"; import { humanizeError } from "@/lib/errors"; +import { api } from "@/lib/invoke"; import { agentDisplayName, type ConfigScope, @@ -253,11 +254,14 @@ export default function MarketplacePage() { const [showInstall, setShowInstall] = useState(false); const [installMode, setInstallMode] = useState<"git" | "local">("git"); // Hermes category picker state (marketplace install) - const [hermesPending, setHermesPending] = useState<{ item: MarketplaceItem; scope: ConfigScope } | null>(null); - const [hermesMarketCategories, setHermesMarketCategories] = useState([]); + const [hermesPending, setHermesPending] = useState<{ + item: MarketplaceItem; + scope: ConfigScope; + } | null>(null); + const [hermesMarketCategories, setHermesMarketCategories] = useState< + string[] + >([]); const [hermesMarketCategory, setHermesMarketCategory] = useState("local"); - const [hermesMarketNewMode, setHermesMarketNewMode] = useState(false); - const [hermesMarketNewName, setHermesMarketNewName] = useState(""); const detailPanelRef = useRef(null); const isItemInstalled = ( @@ -361,14 +365,17 @@ export default function MarketplacePage() { const cats = await api.listHermesCategories().catch(() => []); setHermesMarketCategories(cats); setHermesMarketCategory(cats[0] ?? "local"); - setHermesMarketNewMode(false); - setHermesMarketNewName(""); setHermesPending({ item, scope: targetScope }); return; } setError(null); try { - const result = await install(item, targetAgent, targetScope, hermesCategory); + const result = await install( + item, + targetAgent, + targetScope, + hermesCategory, + ); // Refresh extension store so audit page can resolve names immediately useExtensionStore.getState().fetch(); const key = `${item.id}:${targetAgent ?? ""}`; @@ -879,60 +886,43 @@ export default function MarketplacePage() {

Choose a Hermes category

- {hermesMarketNewMode ? ( -
- setHermesMarketNewName(e.target.value)} - placeholder="new-category-name" - className="flex-1 rounded-lg border border-border bg-background px-2.5 py-1 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" - autoFocus - /> - -
- ) : ( -
- {hermesMarketCategories.map((cat) => ( - - ))} - -
- )} +
+ > + Cancel +
)} diff --git a/src/stores/extension-store.ts b/src/stores/extension-store.ts index cda5a27c..50e3a3ff 100644 --- a/src/stores/extension-store.ts +++ b/src/stores/extension-store.ts @@ -64,7 +64,11 @@ interface ExtensionState { updateTags: (groupKey: string, tags: string[]) => Promise; updatePack: (groupKey: string, pack: string | null) => Promise; fetchPacks: () => Promise; - installToAgent: (id: string, targetAgent: string, hermesCategory?: string) => Promise; + installToAgent: ( + id: string, + targetAgent: string, + hermesCategory?: string, + ) => Promise; toggle: (groupKey: string, enabled: boolean) => Promise; batchToggle: (enabled: boolean) => Promise; undoDelete: () => void; From e11f217a1c9de1b54122b76bd063052fb811724e Mon Sep 17 00:00:00 2001 From: RealZST Date: Wed, 3 Jun 2026 23:36:22 +0300 Subject: [PATCH 03/12] refactor(hermes): extract modify_hermes_yaml; route MCP YAML writes through it Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/deployer.rs | 115 ++++++++++++++++----------------- 1 file changed, 55 insertions(+), 60 deletions(-) diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 3fe5bf71..c85744c6 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -294,69 +294,73 @@ fn deploy_mcp_server_opencode(config_path: &Path, entry: &McpServerEntry) -> Res }) } -/// YAML-based MCP deploy for Hermes (`~/.hermes/config.yaml`, "mcp_servers" key). +/// Load config.yaml as a mutable root mapping (empty mapping if absent/blank), +/// run `f`, then atomically write it back. The single primitive every Hermes +/// YAML writer (MCP, hooks, plugins) routes through. /// -/// Reads the full config.yaml, upserts the server entry under `mcp_servers.`, -/// and writes the file back. Command-based entries use `command`/`args`/`env` keys; -/// URL-based entries (where `entry.command` starts with "http") use a `url` key. -/// The rest of config.yaml is preserved through serde_yaml round-trip. -fn deploy_mcp_server_hermes_yaml( +/// Note: CREATES the file (and parent dirs) even on a no-op `f`; remove-style +/// callers that must not create an absent file should pre-check existence. +fn modify_hermes_yaml( config_path: &Path, - entry: &McpServerEntry, + f: impl FnOnce(&mut serde_yaml::Mapping) -> Result<(), HkError>, ) -> Result<(), HkError> { - let parent = config_path - .parent() - .ok_or_else(|| HkError::Validation("Invalid config path".into()))?; - std::fs::create_dir_all(parent)?; - + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent)?; + } let existing = std::fs::read_to_string(config_path).unwrap_or_default(); - let mut doc: serde_yaml::Value = if existing.is_empty() { + let mut doc: serde_yaml::Value = if existing.trim().is_empty() { serde_yaml::Value::Mapping(serde_yaml::Mapping::new()) } else { serde_yaml::from_str(&existing) .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))? }; - let root = doc .as_mapping_mut() .ok_or_else(|| HkError::ConfigCorrupted("config.yaml root is not a mapping".into()))?; + f(root)?; + let output = serde_yaml::to_string(&doc).map_err(|e| HkError::Internal(e.to_string()))?; + atomic_write(config_path, &output)?; + Ok(()) +} - let mcp_key = serde_yaml::Value::String("mcp_servers".into()); - let mcp_servers = root - .entry(mcp_key) - .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) - .as_mapping_mut() - .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; - - let mut server = serde_yaml::Mapping::new(); - if entry.command.starts_with("http://") || entry.command.starts_with("https://") { - server.insert("url".into(), entry.command.clone().into()); - } else { - server.insert("command".into(), entry.command.clone().into()); - if !entry.args.is_empty() { - let args: Vec = - entry.args.iter().cloned().map(serde_yaml::Value::String).collect(); - server.insert("args".into(), serde_yaml::Value::Sequence(args)); - } - if !entry.env.is_empty() { - let mut env = serde_yaml::Mapping::new(); - for (k, v) in &entry.env { - env.insert(k.clone().into(), v.clone().into()); +/// YAML-based MCP deploy for Hermes (`~/.hermes/config.yaml`, "mcp_servers" key). +/// +/// Reads the full config.yaml, upserts the server entry under `mcp_servers.`, +/// and writes the file back. Command-based entries use `command`/`args`/`env` keys; +/// URL-based entries (where `entry.command` starts with "http") use a `url` key. +/// The rest of config.yaml is preserved through serde_yaml round-trip. +fn deploy_mcp_server_hermes_yaml( + config_path: &Path, + entry: &McpServerEntry, +) -> Result<(), HkError> { + modify_hermes_yaml(config_path, |root| { + let servers = root + .entry("mcp_servers".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; + let mut server = serde_yaml::Mapping::new(); + if entry.command.starts_with("http://") || entry.command.starts_with("https://") { + server.insert("url".into(), entry.command.clone().into()); + } else { + server.insert("command".into(), entry.command.clone().into()); + if !entry.args.is_empty() { + let args: Vec = + entry.args.iter().cloned().map(serde_yaml::Value::String).collect(); + server.insert("args".into(), serde_yaml::Value::Sequence(args)); + } + if !entry.env.is_empty() { + let mut env = serde_yaml::Mapping::new(); + for (k, v) in &entry.env { + env.insert(k.clone().into(), v.clone().into()); + } + server.insert("env".into(), serde_yaml::Value::Mapping(env)); } - server.insert("env".into(), serde_yaml::Value::Mapping(env)); } - } - server.insert("enabled".into(), serde_yaml::Value::Bool(true)); - - mcp_servers.insert( - entry.name.clone().into(), - serde_yaml::Value::Mapping(server), - ); - - let output = - serde_yaml::to_string(&doc).map_err(|e| HkError::Internal(e.to_string()))?; - atomic_write(config_path, &output)?; - Ok(()) + server.insert("enabled".into(), serde_yaml::Value::Bool(true)); + servers.insert(entry.name.clone().into(), serde_yaml::Value::Mapping(server)); + Ok(()) + }) } /// Build the `serde_json::Value` shape OpenCode's `McpLocalConfig` schema @@ -543,21 +547,12 @@ pub fn remove_mcp_server( Ok(()) } McpFormat::Opencode => remove_mcp_server_opencode(config_path, server_name), - McpFormat::HermesYaml => { - let content = std::fs::read_to_string(config_path)?; - let mut doc: serde_yaml::Value = serde_yaml::from_str(&content) - .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?; - if let Some(servers) = doc - .get_mut("mcp_servers") - .and_then(|v| v.as_mapping_mut()) - { + McpFormat::HermesYaml => modify_hermes_yaml(config_path, |root| { + if let Some(servers) = root.get_mut("mcp_servers").and_then(|v| v.as_mapping_mut()) { servers.remove(server_name); } - let output = - serde_yaml::to_string(&doc).map_err(|e| HkError::Internal(e.to_string()))?; - atomic_write(config_path, &output)?; Ok(()) - } + }), _ => locked_modify_json(config_path, |config| { let key = json_top_key(format); if let Some(servers) = config.get_mut(key).and_then(|v| v.as_object_mut()) { From f2bffdc235cdaa75ad143266754fb48fe4f9eec6 Mon Sep 17 00:00:00 2001 From: RealZST Date: Wed, 3 Jun 2026 23:50:12 +0300 Subject: [PATCH 04/12] feat(hermes): add hook support (read + deploy + enable/disable + cross-agent) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/adapter/hermes.rs | 71 +++++- crates/hk-core/src/adapter/hook_events.rs | 75 ++++++ crates/hk-core/src/adapter/mod.rs | 4 + crates/hk-core/src/deployer.rs | 271 ++++++++++++++++++++++ 4 files changed, 415 insertions(+), 6 deletions(-) diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index 1b4589f7..b5a6ec95 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -3,7 +3,8 @@ // - "local" is the conventional category for user-managed skills (e.g. installed by HK) // - Nous ships built-in skills in sibling category dirs (apple/, devops/, etc.) // MCP: ~/.hermes/config.yaml — "mcp_servers" YAML mapping -// Hooks: not supported (empty ~/.hermes/hooks/ dir, no documentation) +// Hooks: ~/.hermes/config.yaml root `hooks:` key — list of {matcher?, command, timeout?} +// per event (pre_tool_call/post_tool_call/on_session_start/...). YAML, McpFormat::HermesYaml-style. // Plugins: ~/.hermes/hermes-agent/plugins/ contains internal model-provider adapters — // not user-installable extensions in the HK sense. @@ -208,16 +209,53 @@ impl AgentAdapter for HermesAdapter { } fn hook_format(&self) -> HookFormat { - HookFormat::None + HookFormat::HermesYaml } fn hook_config_path(&self) -> PathBuf { - // Placeholder — never read or written since hook_format() == None. - self.base_dir().join("hooks.unused") + // Hermes hooks live at the root `hooks:` key of config.yaml. + self.base_dir().join("config.yaml") } fn read_hooks(&self) -> Vec { - vec![] + self.read_hooks_from(&self.hook_config_path()) + } + + fn read_hooks_from(&self, path: &Path) -> Vec { + let Some(config) = Self::parse_yaml(path) else { + return vec![]; + }; + let Some(hooks) = config.get("hooks").and_then(|v| v.as_mapping()) else { + return vec![]; + }; + let mut out = Vec::new(); + for (event_key, list) in hooks { + let Some(event) = event_key.as_str() else { + continue; + }; + let Some(items) = list.as_sequence() else { + continue; + }; + for item in items { + let Some(command) = item.get("command").and_then(|v| v.as_str()) else { + continue; + }; + let matcher = item + .get("matcher") + .and_then(|v| v.as_str()) + .map(String::from); + out.push(HookEntry { + event: event.to_string(), + matcher, + command: command.to_string(), + }); + } + } + out + } + + fn translate_hook_event(&self, event: &str) -> Option { + super::hook_events::to_hermes(event) } fn plugin_dirs(&self) -> Vec { @@ -349,10 +387,31 @@ mod tests { #[test] fn test_read_hooks_empty() { - let adapter = HermesAdapter::new(); + let tmp = tempfile::tempdir().unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); assert!(adapter.read_hooks().is_empty()); } + #[test] + fn test_read_hooks_parses_config_yaml() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".hermes"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("config.yaml"), + "hooks:\n pre_tool_call:\n - matcher: terminal\n command: ~/.hermes/agent-hooks/block.sh\n timeout: 5\n on_session_start:\n - command: ~/.hermes/agent-hooks/log.sh\n", + ) + .unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let hooks = adapter.read_hooks(); + assert_eq!(hooks.len(), 2); + let pre = hooks.iter().find(|h| h.event == "pre_tool_call").unwrap(); + assert_eq!(pre.matcher.as_deref(), Some("terminal")); + assert_eq!(pre.command, "~/.hermes/agent-hooks/block.sh"); + let sess = hooks.iter().find(|h| h.event == "on_session_start").unwrap(); + assert_eq!(sess.matcher, None); + } + #[test] fn test_read_mcp_servers_url_entry() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/hk-core/src/adapter/hook_events.rs b/crates/hk-core/src/adapter/hook_events.rs index f2eea4ac..7041f824 100644 --- a/crates/hk-core/src/adapter/hook_events.rs +++ b/crates/hk-core/src/adapter/hook_events.rs @@ -294,6 +294,49 @@ const WINDSURF_EVENTS: &[EventMapping] = &[ }, ]; +/// Hermes event mappings. Lifecycle events from ~/.hermes/config.yaml `hooks:`. +/// Reference: https://hermes-agent.nousresearch.com/docs/user-guide/features/hooks +const HERMES_EVENTS: &[EventMapping] = &[ + // --- Mapped (canonical equivalents) --- + EventMapping { + canonical: "PreToolUse", + agent: "pre_tool_call", + }, + EventMapping { + canonical: "PostToolUse", + agent: "post_tool_call", + }, + EventMapping { + canonical: "SessionStart", + agent: "on_session_start", + }, + EventMapping { + canonical: "SessionEnd", + agent: "on_session_end", + }, + EventMapping { + canonical: "SubagentStop", + agent: "subagent_stop", + }, + // --- Hermes-specific (no canonical equivalent; passthrough only) --- + EventMapping { + canonical: "pre_llm_call", + agent: "pre_llm_call", + }, + EventMapping { + canonical: "post_llm_call", + agent: "post_llm_call", + }, + EventMapping { + canonical: "on_session_finalize", + agent: "on_session_finalize", + }, + EventMapping { + canonical: "on_session_reset", + agent: "on_session_reset", + }, +]; + /// Translate an event name from any agent's convention to the target agent's convention. /// Returns None if the event has no equivalent in the target agent. fn translate( @@ -327,6 +370,7 @@ pub fn to_claude(event: &str) -> Option { .or_else(|| translate(event, CURSOR_EVENTS, CLAUDE_EVENTS)) .or_else(|| translate(event, COPILOT_EVENTS, CLAUDE_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, CLAUDE_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, CLAUDE_EVENTS)) } /// Translate an event name to Gemini convention. @@ -336,6 +380,7 @@ pub fn to_gemini(event: &str) -> Option { .or_else(|| translate(event, CURSOR_EVENTS, GEMINI_EVENTS)) .or_else(|| translate(event, COPILOT_EVENTS, GEMINI_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, GEMINI_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, GEMINI_EVENTS)) } /// Translate an event name to Cursor convention. @@ -345,6 +390,7 @@ pub fn to_cursor(event: &str) -> Option { .or_else(|| translate(event, GEMINI_EVENTS, CURSOR_EVENTS)) .or_else(|| translate(event, COPILOT_EVENTS, CURSOR_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, CURSOR_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, CURSOR_EVENTS)) } /// Translate an event name to Copilot convention. @@ -354,6 +400,7 @@ pub fn to_copilot(event: &str) -> Option { .or_else(|| translate(event, GEMINI_EVENTS, COPILOT_EVENTS)) .or_else(|| translate(event, CURSOR_EVENTS, COPILOT_EVENTS)) .or_else(|| translate(event, WINDSURF_EVENTS, COPILOT_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, COPILOT_EVENTS)) } /// Translate an event name to Windsurf convention. @@ -363,6 +410,17 @@ pub fn to_windsurf(event: &str) -> Option { .or_else(|| translate(event, GEMINI_EVENTS, WINDSURF_EVENTS)) .or_else(|| translate(event, CURSOR_EVENTS, WINDSURF_EVENTS)) .or_else(|| translate(event, COPILOT_EVENTS, WINDSURF_EVENTS)) + .or_else(|| translate(event, HERMES_EVENTS, WINDSURF_EVENTS)) +} + +/// Translate an event name to Hermes convention. +pub fn to_hermes(event: &str) -> Option { + translate(event, HERMES_EVENTS, HERMES_EVENTS) + .or_else(|| translate(event, CLAUDE_EVENTS, HERMES_EVENTS)) + .or_else(|| translate(event, GEMINI_EVENTS, HERMES_EVENTS)) + .or_else(|| translate(event, CURSOR_EVENTS, HERMES_EVENTS)) + .or_else(|| translate(event, COPILOT_EVENTS, HERMES_EVENTS)) + .or_else(|| translate(event, WINDSURF_EVENTS, HERMES_EVENTS)) } #[cfg(test)] @@ -501,4 +559,21 @@ mod tests { assert_eq!(to_windsurf("preToolUse"), None); assert_eq!(to_windsurf("postToolUse"), None); } + + #[test] + fn test_hermes_event_translation() { + // canonical → hermes + assert_eq!(to_hermes("PreToolUse").as_deref(), Some("pre_tool_call")); + assert_eq!(to_hermes("PostToolUse").as_deref(), Some("post_tool_call")); + assert_eq!(to_hermes("SessionStart").as_deref(), Some("on_session_start")); + assert_eq!(to_hermes("SubagentStop").as_deref(), Some("subagent_stop")); + // hermes native passthrough + assert_eq!(to_hermes("pre_tool_call").as_deref(), Some("pre_tool_call")); + // hermes → claude (cross-agent) + assert_eq!(to_claude("pre_tool_call").as_deref(), Some("PreToolUse")); + // hermes-specific event has no canonical equivalent + assert_eq!(to_claude("pre_llm_call"), None); + // but passes through to itself + assert_eq!(to_hermes("pre_llm_call").as_deref(), Some("pre_llm_call")); + } } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 8903b1a4..fdad6f86 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -93,6 +93,10 @@ pub enum HookFormat { Copilot, /// Windsurf: {"hooks": {"event": [{"command": "cmd"}]}} Windsurf, + /// Hermes: YAML config.yaml with root `hooks:` key. Each `hooks.` + /// is a list of `{matcher?, command, timeout?}`. Routed through dedicated + /// YAML helpers in deployer.rs (NOT locked_modify_json, which is JSON-only). + HermesYaml, /// Agent does not support hooks None, } diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index c85744c6..71e5b3ae 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -363,6 +363,130 @@ fn deploy_mcp_server_hermes_yaml( }) } +/// True if a hooks-list item matches (matcher, command). +fn hermes_hook_item_matches( + item: &serde_yaml::Value, + matcher: Option<&str>, + command: &str, +) -> bool { + let item_cmd = item.get("command").and_then(|v| v.as_str()); + let item_matcher = item.get("matcher").and_then(|v| v.as_str()); + item_cmd == Some(command) && item_matcher == matcher +} + +/// YAML-based hook deploy for Hermes (`~/.hermes/config.yaml`, root "hooks" key). +/// Upserts `{matcher?, command}` under `hooks.` (a list), preserving the +/// rest of config.yaml. Deduplicates on (matcher, command). +fn deploy_hook_hermes_yaml(config_path: &Path, entry: &HookEntry) -> Result<(), HkError> { + modify_hermes_yaml(config_path, |root| { + let hooks = root + .entry("hooks".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("hooks is not a mapping".into()))?; + let list = hooks + .entry(entry.event.clone().into()) + .or_insert_with(|| serde_yaml::Value::Sequence(vec![])) + .as_sequence_mut() + .ok_or_else(|| HkError::ConfigCorrupted("hook event is not a sequence".into()))?; + if list + .iter() + .any(|i| hermes_hook_item_matches(i, entry.matcher.as_deref(), &entry.command)) + { + return Ok(()); // dedup + } + let mut item = serde_yaml::Mapping::new(); + if let Some(m) = &entry.matcher { + item.insert("matcher".into(), m.clone().into()); + } + item.insert("command".into(), entry.command.clone().into()); + list.push(serde_yaml::Value::Mapping(item)); + Ok(()) + }) +} + +/// YAML-based hook remove for Hermes. Drops the matching `{matcher?, command}` +/// item from `hooks.`; removes the event key entirely if it becomes empty. +fn remove_hook_hermes_yaml( + config_path: &Path, + event: &str, + matcher: Option<&str>, + command: &str, +) -> Result<(), HkError> { + if !config_path.exists() { + return Ok(()); + } + modify_hermes_yaml(config_path, |root| { + let Some(hooks) = root.get_mut("hooks").and_then(|v| v.as_mapping_mut()) else { + return Ok(()); + }; + if let Some(list) = hooks.get_mut(event).and_then(|v| v.as_sequence_mut()) { + list.retain(|i| !hermes_hook_item_matches(i, matcher, command)); + if list.is_empty() { + hooks.remove(event); + } + } + Ok(()) + }) +} + +/// YAML-based hook restore for Hermes. Pushes the previously-saved entry (stored +/// as a `serde_json::Value` by `read_hook_config_hermes_yaml`) back under +/// `hooks.`. +fn restore_hook_hermes_yaml( + config_path: &Path, + event: &str, + entry: &serde_json::Value, +) -> Result<(), HkError> { + let yaml_item: serde_yaml::Value = + serde_yaml::to_value(entry).map_err(|e| HkError::Internal(e.to_string()))?; + modify_hermes_yaml(config_path, |root| { + let hooks = root + .entry("hooks".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("hooks is not a mapping".into()))?; + let list = hooks + .entry(event.to_string().into()) + .or_insert_with(|| serde_yaml::Value::Sequence(vec![])) + .as_sequence_mut() + .ok_or_else(|| HkError::ConfigCorrupted("hook event is not a sequence".into()))?; + list.push(yaml_item); + Ok(()) + }) +} + +/// YAML-based hook read for Hermes. Returns the matching `hooks.` item +/// converted to a `serde_json::Value` (mirrors the JSON formats' saved-entry type). +fn read_hook_config_hermes_yaml( + config_path: &Path, + event: &str, + matcher: Option<&str>, + command: &str, +) -> Result, HkError> { + if !config_path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(config_path)?; + let doc: serde_yaml::Value = serde_yaml::from_str(&content) + .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?; + let Some(item) = doc + .get("hooks") + .and_then(|h| h.get(event)) + .and_then(|v| v.as_sequence()) + .and_then(|seq| { + seq.iter() + .find(|i| hermes_hook_item_matches(i, matcher, command)) + }) + else { + return Ok(None); + }; + let json_str = serde_json::to_string(item).map_err(|e| HkError::Internal(e.to_string()))?; + let json_val = + serde_json::from_str(&json_str).map_err(|e| HkError::Internal(e.to_string()))?; + Ok(Some(json_val)) +} + /// Build the `serde_json::Value` shape OpenCode's `McpLocalConfig` schema /// expects for one server entry. Shared by `deploy_mcp_server_opencode` /// (cross-agent install path) and intentionally also reachable as the @@ -397,6 +521,9 @@ pub fn deploy_hook( entry: &HookEntry, format: HookFormat, ) -> Result<(), HkError> { + if format == HookFormat::HermesYaml { + return deploy_hook_hermes_yaml(config_path, entry); + } locked_modify_json(config_path, |config| { match format { HookFormat::ClaudeLike => { @@ -511,6 +638,10 @@ pub fn deploy_hook( arr.push(hook_val); } } + HookFormat::HermesYaml => { + // Handled by the early return above; YAML is not JSON. + unreachable!("HermesYaml handled before locked_modify_json") + } HookFormat::None => { return Err(HkError::Internal("Agent does not support hooks".into())); } @@ -590,6 +721,9 @@ pub fn remove_hook( command: &str, format: HookFormat, ) -> Result<(), HkError> { + if format == HookFormat::HermesYaml { + return remove_hook_hermes_yaml(config_path, event, matcher, command); + } if !config_path.exists() { return Ok(()); } @@ -663,6 +797,10 @@ pub fn remove_hook( } } } + HookFormat::HermesYaml => { + // Handled by the early return above; YAML is not JSON. + unreachable!("HermesYaml handled before locked_modify_json") + } HookFormat::None => { return Err(HkError::Internal("Agent does not support hooks".into())); } @@ -809,6 +947,9 @@ pub fn restore_hook( entry: &serde_json::Value, format: HookFormat, ) -> Result<(), HkError> { + if format == HookFormat::HermesYaml { + return restore_hook_hermes_yaml(config_path, event, entry); + } locked_modify_json(config_path, |config| { match format { HookFormat::ClaudeLike => { @@ -864,6 +1005,10 @@ pub fn restore_hook( .ok_or_else(|| HkError::ConfigCorrupted("hook event is not an array".into()))?; arr.push(entry.clone()); } + HookFormat::HermesYaml => { + // Handled by the early return above; YAML is not JSON. + unreachable!("HermesYaml handled before locked_modify_json") + } HookFormat::None => { return Err(HkError::Internal("Agent does not support hooks".into())); } @@ -1304,6 +1449,9 @@ pub fn read_hook_config( command: &str, format: HookFormat, ) -> Result, HkError> { + if format == HookFormat::HermesYaml { + return read_hook_config_hermes_yaml(config_path, event, matcher, command); + } if !config_path.exists() { return Ok(None); } @@ -1361,6 +1509,8 @@ pub fn read_hook_config( } Ok(None) } + // Handled by the early return above; YAML is not JSON. + HookFormat::HermesYaml => Ok(None), HookFormat::None => Ok(None), } } @@ -2593,6 +2743,127 @@ mod tests { assert_eq!(hooks[0]["command"], "echo other"); } + #[test] + fn test_hermes_yaml_hook_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "model:\n default: x\n").unwrap(); + let entry = HookEntry { + event: "pre_tool_call".into(), + matcher: Some("terminal".into()), + command: "~/.hermes/agent-hooks/block.sh".into(), + }; + deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!( + doc.get("model") + .and_then(|m| m.get("default")) + .and_then(|v| v.as_str()), + Some("x") + ); + let saved = read_hook_config( + &cfg, + "pre_tool_call", + Some("terminal"), + "~/.hermes/agent-hooks/block.sh", + HookFormat::HermesYaml, + ) + .unwrap(); + assert!(saved.is_some()); + remove_hook( + &cfg, + "pre_tool_call", + Some("terminal"), + "~/.hermes/agent-hooks/block.sh", + HookFormat::HermesYaml, + ) + .unwrap(); + let after: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert!( + after + .get("hooks") + .and_then(|h| h.get("pre_tool_call")) + .is_none() + ); + restore_hook(&cfg, "pre_tool_call", &saved.unwrap(), HookFormat::HermesYaml).unwrap(); + let restored = read_hook_config( + &cfg, + "pre_tool_call", + Some("terminal"), + "~/.hermes/agent-hooks/block.sh", + HookFormat::HermesYaml, + ) + .unwrap(); + assert!(restored.is_some()); + } + + #[test] + fn test_hermes_yaml_hook_deploy_dedup() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "model:\n default: x\n").unwrap(); + let entry = HookEntry { + event: "pre_tool_call".into(), + matcher: Some("terminal".into()), + command: "~/.hermes/agent-hooks/block.sh".into(), + }; + // Deploying the identical hook twice must not duplicate the list item. + deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap(); + deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let seq = doc + .get("hooks") + .and_then(|h| h.get("pre_tool_call")) + .and_then(|v| v.as_sequence()) + .expect("pre_tool_call should be a sequence"); + assert_eq!(seq.len(), 1, "duplicate deploy should be deduped"); + } + + #[test] + fn test_hermes_yaml_hook_matcherless_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "model:\n default: x\n").unwrap(); + let entry = HookEntry { + event: "on_session_start".into(), + matcher: None, + command: "~/.hermes/agent-hooks/log.sh".into(), + }; + deploy_hook(&cfg, &entry, HookFormat::HermesYaml).unwrap(); + + // read_hook_config with matcher=None finds the matcher-less entry. + let saved = read_hook_config( + &cfg, + "on_session_start", + None, + "~/.hermes/agent-hooks/log.sh", + HookFormat::HermesYaml, + ) + .unwrap(); + assert!(saved.is_some()); + + // The written item must carry no `matcher` key. + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let item = doc + .get("hooks") + .and_then(|h| h.get("on_session_start")) + .and_then(|v| v.as_sequence()) + .and_then(|seq| seq.first()) + .expect("on_session_start should have one item"); + assert!( + item.get("matcher").is_none(), + "matcher-less hook must not write a matcher key" + ); + assert_eq!( + item.get("command").and_then(|v| v.as_str()), + Some("~/.hermes/agent-hooks/log.sh") + ); + } + #[test] fn test_copy_dir_recursive_skips_symlinks() { let src_dir = TempDir::new().unwrap(); From 5f9f8fcfa08feabe2fa151d1f4f0a1b606101580 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 4 Jun 2026 00:07:16 +0300 Subject: [PATCH 05/12] feat(hermes): add plugin support (read + enable/disable via config.yaml plugins.enabled) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/adapter/hermes.rs | 170 ++++++++++++++++++++++++++- crates/hk-core/src/deployer.rs | 76 ++++++++++++ crates/hk-core/src/manager.rs | 4 + 3 files changed, 246 insertions(+), 4 deletions(-) diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index b5a6ec95..a1190593 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -5,10 +5,12 @@ // MCP: ~/.hermes/config.yaml — "mcp_servers" YAML mapping // Hooks: ~/.hermes/config.yaml root `hooks:` key — list of {matcher?, command, timeout?} // per event (pre_tool_call/post_tool_call/on_session_start/...). YAML, McpFormat::HermesYaml-style. -// Plugins: ~/.hermes/hermes-agent/plugins/ contains internal model-provider adapters — -// not user-installable extensions in the HK sense. +// Plugins: ~/.hermes/plugins//plugin.yaml (flat) or ...///plugin.yaml +// (one nesting level). Enable-state in config.yaml `plugins.enabled` (disabled by default). -use super::{AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, ProjectMarker}; +use super::{ + AgentAdapter, HookEntry, HookFormat, McpFormat, McpServerEntry, PluginEntry, ProjectMarker, +}; use std::path::{Path, PathBuf}; pub struct HermesAdapter { @@ -62,6 +64,55 @@ impl HermesAdapter { let content = std::fs::read_to_string(path).ok()?; serde_yaml::from_str(&content).ok() } + + /// Names listed under `plugins.enabled` in config.yaml (empty if absent). + fn enabled_plugins(&self) -> std::collections::HashSet { + let mut set = std::collections::HashSet::new(); + if let Some(cfg) = Self::parse_yaml(&self.base_dir().join("config.yaml")) { + if let Some(list) = cfg + .get("plugins") + .and_then(|p| p.get("enabled")) + .and_then(|v| v.as_sequence()) + { + for v in list { + if let Some(s) = v.as_str() { + set.insert(s.to_string()); + } + } + } + } + set + } + + /// Build a `PluginEntry` from a `plugin.yaml` manifest path. The plugin name + /// comes from the manifest's `name` key, falling back to the parent directory + /// name. Returns `None` if the manifest can't be parsed or no name is derivable. + fn plugin_entry_from_manifest( + manifest: &Path, + enabled: &std::collections::HashSet, + ) -> Option { + let parsed = Self::parse_yaml(manifest)?; + let dir = manifest.parent().map(PathBuf::from); + let name = parsed + .get("name") + .and_then(|v| v.as_str()) + .map(String::from) + .or_else(|| { + dir.as_ref() + .and_then(|d| d.file_name()) + .and_then(|n| n.to_str()) + .map(String::from) + })?; + Some(PluginEntry { + enabled: enabled.contains(&name), + name, + source: "hermes".into(), + path: dir, + uri: None, + installed_at: None, + updated_at: None, + }) + } } impl AgentAdapter for HermesAdapter { @@ -259,7 +310,50 @@ impl AgentAdapter for HermesAdapter { } fn plugin_dirs(&self) -> Vec { - vec![] + vec![self.base_dir().join("plugins")] + } + + fn plugin_config_path(&self) -> PathBuf { + self.base_dir().join("config.yaml") + } + + fn read_plugins(&self) -> Vec { + let root = self.base_dir().join("plugins"); + let enabled = self.enabled_plugins(); + let mut out = Vec::new(); + // Single pass: discover plugin.yaml at depth 1 (flat) and depth 2 + // (category-nested), building each PluginEntry as it is found. + let Ok(level1) = std::fs::read_dir(&root) else { + return out; + }; + for e1 in level1.flatten() { + let p1 = e1.path(); + if !p1.is_dir() { + continue; + } + // Flat plugin: //plugin.yaml. Wins over any nested + // manifest, so don't descend into this directory. + let flat = p1.join("plugin.yaml"); + if flat.is_file() { + if let Some(entry) = Self::plugin_entry_from_manifest(&flat, &enabled) { + out.push(entry); + } + continue; + } + // Category-nested plugin: ///plugin.yaml. + let Ok(level2) = std::fs::read_dir(&p1) else { + continue; + }; + for e2 in level2.flatten() { + let nested = e2.path().join("plugin.yaml"); + if nested.is_file() { + if let Some(entry) = Self::plugin_entry_from_manifest(&nested, &enabled) { + out.push(entry); + } + } + } + } + out } fn list_skill_categories(&self) -> Vec { @@ -457,6 +551,74 @@ mod tests { assert!(adapter.read_mcp_servers().is_empty()); } + #[test] + fn test_read_plugins_flat_and_nested_with_enabled_state() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".hermes"); + fs::create_dir_all(dir.join("plugins/calculator")).unwrap(); + fs::create_dir_all(dir.join("plugins/devtools/git-helper")).unwrap(); + fs::write(dir.join("plugins/calculator/plugin.yaml"), "name: calculator\nversion: 1.0.0\n").unwrap(); + fs::write(dir.join("plugins/devtools/git-helper/plugin.yaml"), "name: git-helper\nversion: 0.1.0\n").unwrap(); + fs::write(dir.join("config.yaml"), "plugins:\n enabled:\n - calculator\n").unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let plugins = adapter.read_plugins(); + assert_eq!(plugins.len(), 2); + let calc = plugins.iter().find(|p| p.name == "calculator").unwrap(); + assert!(calc.enabled, "listed in plugins.enabled"); + let git = plugins.iter().find(|p| p.name == "git-helper").unwrap(); + assert!(!git.enabled, "not listed → disabled by default"); + } + + #[test] + fn test_read_plugins_falls_back_to_dirname_when_no_name_key() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".hermes"); + fs::create_dir_all(dir.join("plugins/no-name-plugin")).unwrap(); + fs::write( + dir.join("plugins/no-name-plugin/plugin.yaml"), + "version: 1.0.0\n", + ) + .unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let plugins = adapter.read_plugins(); + assert_eq!(plugins.len(), 1); + assert_eq!( + plugins[0].name, "no-name-plugin", + "manifest without a name key falls back to the directory name" + ); + } + + #[test] + fn test_read_plugins_flat_wins_does_not_descend() { + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join(".hermes"); + // A flat plugin that ALSO contains a nested plugin.yaml underneath it. + fs::create_dir_all(dir.join("plugins/calculator/sub")).unwrap(); + fs::write( + dir.join("plugins/calculator/plugin.yaml"), + "name: calculator\nversion: 1.0.0\n", + ) + .unwrap(); + fs::write( + dir.join("plugins/calculator/sub/plugin.yaml"), + "name: sub\nversion: 2.0.0\n", + ) + .unwrap(); + let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); + let plugins = adapter.read_plugins(); + // Flat plugin wins: calculator appears once, the nested `sub` is not surfaced. + assert_eq!(plugins.len(), 1); + assert_eq!( + plugins.iter().filter(|p| p.name == "calculator").count(), + 1, + "flat plugin counted exactly once" + ); + assert!( + !plugins.iter().any(|p| p.name == "sub"), + "do not descend into a flat plugin directory" + ); + } + #[test] fn test_skill_dir_for_category_resolves_global_and_project() { use crate::models::ConfigScope; diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 71e5b3ae..30bf59f9 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -363,6 +363,34 @@ fn deploy_mcp_server_hermes_yaml( }) } +/// Add/remove a plugin name under `plugins.enabled` in Hermes config.yaml. +/// Hermes plugins are disabled by default; presence in the list = enabled. +pub fn set_hermes_plugin_enabled( + config_path: &Path, + name: &str, + enabled: bool, +) -> Result<(), HkError> { + modify_hermes_yaml(config_path, |root| { + let plugins = root + .entry("plugins".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("plugins is not a mapping".into()))?; + let list = plugins + .entry("enabled".into()) + .or_insert_with(|| serde_yaml::Value::Sequence(vec![])) + .as_sequence_mut() + .ok_or_else(|| HkError::ConfigCorrupted("plugins.enabled is not a sequence".into()))?; + let present = list.iter().any(|v| v.as_str() == Some(name)); + if enabled && !present { + list.push(serde_yaml::Value::String(name.to_string())); + } else if !enabled && present { + list.retain(|v| v.as_str() != Some(name)); + } + Ok(()) + }) +} + /// True if a hooks-list item matches (matcher, command). fn hermes_hook_item_matches( item: &serde_yaml::Value, @@ -2864,6 +2892,54 @@ mod tests { ); } + #[test] + fn test_set_hermes_plugin_enabled_toggles_list() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "plugins:\n enabled:\n - calculator\n").unwrap(); + set_hermes_plugin_enabled(&cfg, "weather", true).unwrap(); + let doc: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let list: Vec<&str> = doc["plugins"]["enabled"].as_sequence().unwrap() + .iter().filter_map(|v| v.as_str()).collect(); + assert!(list.contains(&"calculator") && list.contains(&"weather")); + set_hermes_plugin_enabled(&cfg, "calculator", false).unwrap(); + let doc2: serde_yaml::Value = serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let list2: Vec<&str> = doc2["plugins"]["enabled"].as_sequence().unwrap() + .iter().filter_map(|v| v.as_str()).collect(); + assert!(!list2.contains(&"calculator") && list2.contains(&"weather")); + } + + #[test] + fn test_set_hermes_plugin_enabled_is_idempotent() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "plugins:\n enabled:\n - calculator\n").unwrap(); + + // Enabling an already-enabled plugin must not duplicate it. + set_hermes_plugin_enabled(&cfg, "calculator", true).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let list: Vec<&str> = doc["plugins"]["enabled"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!(list, vec!["calculator"], "no duplicate on re-enable"); + + // Disabling an absent plugin must be a clean no-op. + set_hermes_plugin_enabled(&cfg, "ghost", false).unwrap(); + let doc2: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let list2: Vec<&str> = doc2["plugins"]["enabled"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!(list2, vec!["calculator"], "disabling absent plugin is a no-op"); + } + #[test] fn test_copy_dir_recursive_skips_symlinks() { let src_dir = TempDir::new().unwrap(); diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 72f59c12..272bba87 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -376,6 +376,10 @@ fn toggle_plugin(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box< .ok_or_else(|| HkError::Internal("Cannot determine home directory".into()))?; deployer::set_gemini_extension_enabled(&extensions_dir, &ext.name, enabled, &home)?; store.set_disabled_config(&ext.id, None)?; + } else if a.name() == "hermes" { + let config_path = a.plugin_config_path(); + deployer::set_hermes_plugin_enabled(&config_path, &ext.name, enabled)?; + store.set_disabled_config(&ext.id, None)?; } else if a.name() == "copilot" { // Check if this is a VS Code agent plugin (has uri from read_plugins). // If so, toggle via state.vscdb. Otherwise fall through to manifest rename. From 9f53db542f3c9c9f5811d74574a25d2df98c7019 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 4 Jun 2026 00:20:04 +0300 Subject: [PATCH 06/12] =?UTF-8?q?fix(hermes):=20preserve=20advanced=20MCP?= =?UTF-8?q?=20keys=20(tools/sampling/headers)=20on=20disable=E2=86=92enabl?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/deployer.rs | 79 +++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 30 deletions(-) diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index 30bf59f9..da2c83f7 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -897,36 +897,20 @@ pub fn restore_mcp_server( } McpFormat::Opencode => restore_mcp_server_opencode(config_path, server_name, entry), McpFormat::HermesYaml => { - // Reconstruct a McpServerEntry from the saved JSON blob and re-deploy via YAML. - let mcp_entry = McpServerEntry { - name: server_name.to_string(), - command: entry - .get("command") - .and_then(|v| v.as_str()) - .or_else(|| entry.get("url").and_then(|v| v.as_str())) - .unwrap_or("") - .into(), - args: entry - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .collect() - }) - .unwrap_or_default(), - env: entry - .get("env") - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default(), - enabled: true, - }; - deploy_mcp_server_hermes_yaml(config_path, &mcp_entry) + // Write the full saved entry back verbatim (JSON → YAML), preserving + // advanced keys (tools/sampling/headers/ssl_verify/timeout) that a + // 5-field McpServerEntry can't carry. Mirrors the JSON restore arm. + let yaml_entry: serde_yaml::Value = + serde_yaml::to_value(entry).map_err(|e| HkError::Internal(e.to_string()))?; + modify_hermes_yaml(config_path, |root| { + let servers = root + .entry("mcp_servers".into()) + .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) + .as_mapping_mut() + .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; + servers.insert(server_name.to_string().into(), yaml_entry); + Ok(()) + }) } _ => { let key = json_top_key(format); @@ -2827,6 +2811,41 @@ mod tests { assert!(restored.is_some()); } + #[test] + fn test_hermes_mcp_restore_preserves_advanced_keys() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "mcp_servers: {}\n").unwrap(); + // Simulate the DB snapshot taken on disable: full entry incl. advanced keys. + let saved = serde_json::json!({ + "command": "npx", + "args": ["-y", "srv"], + "env": {"API_KEY": ""}, + "tools": {"include": ["a", "b"]}, + "enabled": true + }); + restore_mcp_server(&cfg, "srv", &saved, McpFormat::HermesYaml).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let srv = doc.get("mcp_servers").and_then(|m| m.get("srv")).unwrap(); + assert_eq!(srv.get("command").and_then(|v| v.as_str()), Some("npx")); + // advanced key survives + let include: Vec<&str> = srv["tools"]["include"] + .as_sequence() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert_eq!(include, vec!["a", "b"]); + // nested env mapping round-trips through the verbatim JSON→YAML write + assert_eq!( + srv.get("env") + .and_then(|e| e.get("API_KEY")) + .and_then(|v| v.as_str()), + Some("") + ); + } + #[test] fn test_hermes_yaml_hook_deploy_dedup() { let tmp = tempfile::tempdir().unwrap(); From 99d5fc5d03d07c662ee3e5fd6e37ed28d53ff6bc Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 4 Jun 2026 00:37:36 +0300 Subject: [PATCH 07/12] feat(hermes): make skills global-only; disable project scope in UI with tooltip Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/adapter/hermes.rs | 62 +++++++------- crates/hk-core/src/adapter/mod.rs | 8 +- crates/hk-core/src/scanner.rs | 4 + src/components/extensions/install-dialog.tsx | 85 +++++++++++++++----- src/lib/__tests__/agent-capabilities.test.ts | 31 +++++++ src/lib/agent-capabilities.ts | 8 +- 6 files changed, 145 insertions(+), 53 deletions(-) create mode 100644 src/lib/__tests__/agent-capabilities.test.ts diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index a1190593..845421ee 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -180,10 +180,10 @@ impl AgentAdapter for HermesAdapter { } fn project_skill_dirs(&self) -> Vec { - // "local" sub-category mirrors the global convention: user-managed skills - // land in .hermes/skills/local/, keeping them separate from any Nous-shipped - // category dirs that might appear in a future project-level skills feature. - vec![".hermes/skills/local".into()] + // Hermes is global-only: skills live in ~/.hermes/skills/{category}/. + // Project-local skill discovery is an upstream feature request + // (NousResearch/hermes-agent#4667), not yet shipped. + vec![] } fn mcp_config_path(&self) -> PathBuf { @@ -361,21 +361,20 @@ impl AgentAdapter for HermesAdapter { } /// Hermes organises skills into named category subdirectories. Resolve the - /// install target as `~/.hermes/skills/{category}` (global) or - /// `/.hermes/skills/{category}` (project). + /// install target as `~/.hermes/skills/{category}` (global). Hermes is + /// global-only — project scope resolves to `None` (hermes-agent#4667). fn skill_dir_for_category( &self, scope: &crate::models::ConfigScope, category: &str, ) -> Option { - Some(match scope { + match scope { crate::models::ConfigScope::Global => { - self.base_dir().join("skills").join(category) + Some(self.base_dir().join("skills").join(category)) } - crate::models::ConfigScope::Project { path, .. } => { - Path::new(path).join(".hermes").join("skills").join(category) - } - }) + // Global-only: no project-level skills (hermes-agent#4667). + crate::models::ConfigScope::Project { .. } => None, + } } // --- Config file discovery (Agents page) --- @@ -402,10 +401,9 @@ impl AgentAdapter for HermesAdapter { } fn project_markers(&self) -> Vec { - // Hermes has no native project config convention. The marker is the - // directory HarnessKit itself creates when installing project skills, - // so existing HK-managed projects are recognized on re-scan. - vec![ProjectMarker::Dir(".hermes/skills/local")] + // Hermes has no project-level config; it never marks a project dir. + // Skills are global-only (~/.hermes/skills/{category}/, hermes-agent#4667). + vec![] } fn project_mcp_config_relpath(&self) -> Option { @@ -474,9 +472,10 @@ mod tests { } #[test] - fn test_project_skill_dirs() { + fn test_project_skill_dirs_empty_global_only() { + // Hermes has no project-level skill concept (docs + hermes-agent#4667). let adapter = HermesAdapter::new(); - assert_eq!(adapter.project_skill_dirs(), vec![".hermes/skills/local"]); + assert!(adapter.project_skill_dirs().is_empty()); } #[test] @@ -620,7 +619,7 @@ mod tests { } #[test] - fn test_skill_dir_for_category_resolves_global_and_project() { + fn test_skill_dir_for_category_global_only() { use crate::models::ConfigScope; let tmp = tempfile::tempdir().unwrap(); let adapter = HermesAdapter::with_home(tmp.path().to_path_buf()); @@ -630,18 +629,19 @@ mod tests { .expect("hermes resolves a global category dir"); assert!(global.ends_with(".hermes/skills/devops")); - let project = adapter - .skill_dir_for_category( - &ConfigScope::Project { - name: "demo".into(), - path: "/tmp/proj".into(), - }, - "apple", - ) - .expect("hermes resolves a project category dir"); - assert_eq!( - project, - std::path::Path::new("/tmp/proj/.hermes/skills/apple") + // Global-only: project scope never resolves a category dir + // (docs + hermes-agent#4667). + assert!( + adapter + .skill_dir_for_category( + &ConfigScope::Project { + name: "demo".into(), + path: "/tmp/proj".into(), + }, + "apple", + ) + .is_none(), + "hermes is global-only: no project category dir" ); } } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index fdad6f86..6e69b673 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -531,6 +531,9 @@ mod tests { // skill concept, drop it from this assertion explicitly. let adapters = all_adapters(); for a in &adapters { + if a.name() == "hermes" { + continue; // global-only: no project skills (hermes-agent#4667) + } assert!( !a.project_skill_dirs().is_empty(), "{} must declare project_skill_dirs (Universal Agent Skills standard)", @@ -553,11 +556,14 @@ mod tests { ("antigravity", ".agents/skills"), // 1.18.4+ canonical; .agent/ kept as backward-compat alias ("copilot", ".github/skills"), ("opencode", ".opencode/skills"), - ("hermes", ".hermes/skills/local"), // user-managed category; built-ins are in sibling category dirs + // hermes is global-only — no project skill dir (hermes-agent#4667). ] .into_iter() .collect(); for a in &adapters { + if a.name() == "hermes" { + continue; // global-only: no project skills (hermes-agent#4667) + } let actual = a.project_skill_dirs().into_iter().next().unwrap(); let want = expected.get(a.name()).expect("adapter not in expected map"); assert_eq!(&actual, want, "{} project skill path mismatch", a.name()); diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 2c2139c2..48313d41 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -2327,6 +2327,10 @@ mod tests { // exception list explicitly. let adapters = crate::adapter::all_adapters(); for a in &adapters { + if a.name() == "hermes" { + // global-only: no on-disk project convention (hermes-agent#4667) + continue; + } assert!( !a.project_markers().is_empty(), "{} must declare at least one project_marker", diff --git a/src/components/extensions/install-dialog.tsx b/src/components/extensions/install-dialog.tsx index 4134049b..4147cc6e 100644 --- a/src/components/extensions/install-dialog.tsx +++ b/src/components/extensions/install-dialog.tsx @@ -6,6 +6,7 @@ import { HermesCategoryPicker } from "@/components/shared/hermes-category-picker import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useScope } from "@/hooks/use-scope"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import { openDirectoryPicker } from "@/lib/dialog"; import { humanizeError } from "@/lib/errors"; import { api } from "@/lib/invoke"; @@ -29,6 +30,8 @@ type Phase = "input" | "select-skills"; export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { const { t } = useTranslation("extensions"); const { t: tc } = useTranslation("common"); + // Reuse the marketplace "kind not supported at scope" tooltip key. + const { t: tm } = useTranslation("marketplace"); const [source, setSource] = useState(""); const [selectedAgents, setSelectedAgents] = useState>(new Set()); const [loading, setLoading] = useState(false); @@ -62,6 +65,16 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { agentOrder, ); + // Agents that can actually be installed to at the chosen scope. Before a + // target is picked (All-scopes mode) every detected agent is a candidate; + // once a project scope is chosen, scope-incapable agents (e.g. Hermes) drop + // out so bulk select-all never queues them. + const capableAgents = detectedAgents.filter( + (a) => + !installTargetScope || + canInstallAtScope(a.name, "skill", installTargetScope), + ); + // If only one agent detected, auto-select it const singleAgentName = detectedAgents.length === 1 ? detectedAgents[0].name : null; @@ -104,6 +117,19 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { } }, [open, scope]); + // Drop agents that don't support the chosen scope (e.g. Hermes at project). + useEffect(() => { + if (!installTargetScope) return; + setSelectedAgents((prev) => { + const next = new Set( + [...prev].filter((name) => + canInstallAtScope(name, "skill", installTargetScope), + ), + ); + return next.size === prev.size ? prev : next; + }); + }, [installTargetScope]); + useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && open) onClose(); @@ -124,14 +150,17 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { }); }; + // "All" reflects every agent installable at the current scope, so it shows + // checked even when a scope-incapable agent (e.g. Hermes at project) is + // present and hidden from selection. const allAgentsSelected = - detectedAgents.length > 0 && - detectedAgents.every((a) => selectedAgents.has(a.name)); + capableAgents.length > 0 && + capableAgents.every((a) => selectedAgents.has(a.name)); const toggleAllAgents = () => { if (allAgentsSelected) { setSelectedAgents(new Set()); } else { - setSelectedAgents(new Set(detectedAgents.map((a) => a.name))); + setSelectedAgents(new Set(capableAgents.map((a) => a.name))); } }; @@ -329,21 +358,41 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { {t("install.allAgents")} | - {detectedAgents.map((a) => ( - - ))} + {detectedAgents.map((a) => { + // ConfigScope ⊂ ScopeValue, so installTargetScope passes + // through canInstallAtScope's ScopeValue param directly. + const capableAtScope = + !installTargetScope || + canInstallAtScope(a.name, "skill", installTargetScope); + return ( + + ); + })} )} diff --git a/src/lib/__tests__/agent-capabilities.test.ts b/src/lib/__tests__/agent-capabilities.test.ts new file mode 100644 index 00000000..54dd9e3e --- /dev/null +++ b/src/lib/__tests__/agent-capabilities.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; +import type { ScopeValue } from "@/stores/scope-store"; + +const GLOBAL: ScopeValue = { type: "global" }; +const PROJECT: ScopeValue = { + type: "project", + name: "demo", + path: "/tmp/demo", +}; + +describe("canInstallAtScope", () => { + it("returns true for any agent/kind at global scope", () => { + expect(canInstallAtScope("hermes", "skill", GLOBAL)).toBe(true); + expect(canInstallAtScope("claude", "mcp", GLOBAL)).toBe(true); + // Even an unknown agent is unrestricted outside project scope. + expect(canInstallAtScope("totally-unknown", "skill", GLOBAL)).toBe(true); + }); + + it("returns true at project scope for an agent that supports project skills", () => { + expect(canInstallAtScope("claude", "skill", PROJECT)).toBe(true); + }); + + it("returns false at project scope for Hermes (global-only, hermes-agent#4667)", () => { + expect(canInstallAtScope("hermes", "skill", PROJECT)).toBe(false); + }); + + it("returns false at project scope for an unknown agent", () => { + expect(canInstallAtScope("totally-unknown", "skill", PROJECT)).toBe(false); + }); +}); diff --git a/src/lib/agent-capabilities.ts b/src/lib/agent-capabilities.ts index d4d1d336..a4dfb3d2 100644 --- a/src/lib/agent-capabilities.ts +++ b/src/lib/agent-capabilities.ts @@ -4,9 +4,10 @@ import type { ScopeValue } from "@/stores/scope-store"; // Mirrors the per-adapter project_skill_dirs / project_mcp_config_relpath / // project_hook_config_relpath declarations in crates/hk-core/src/adapter/*.rs. // -// All 7 agents support project-level skill via the Universal Agent Skills -// standard (SKILL.md, December 2025). Task 7 declares project_skill_dirs on -// each adapter so this row is always ✓ for skill in v1. +// All agents except Hermes support project-level skill via the Universal Agent +// Skills standard (SKILL.md, December 2025); Hermes is global-only +// (hermes-agent#4667). Each adapter declares project_skill_dirs so this row is +// ✓ for skill in v1 — except Hermes, which has an empty set below. // // "mcp" / "hook" / "cli" rows are forward-compat for v2 cross-agent deploy // (see follow-up roadmap). Several adapters need MCP/hook completion before @@ -22,6 +23,7 @@ const PROJECT_INSTALL_SUPPORT: Record> = { antigravity: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) copilot: new Set(["skill"]), // MCP adapter completion deferred (v2) opencode: new Set(["skill", "mcp"]), // hook unsupported (HookFormat::None) + hermes: new Set(), // global-only: no project skills (hermes-agent#4667) }; /** Whether the agent's adapter declares project-level support for this kind. From baa81d2f3ae309bf4848933ade295314311e41f5 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 5 Jun 2026 16:42:53 +0300 Subject: [PATCH 08/12] feat(hermes): add set_hermes_mcp_enabled (in-place native MCP enable/disable) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/deployer.rs | 72 ++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index da2c83f7..c9e443b0 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -391,6 +391,36 @@ pub fn set_hermes_plugin_enabled( }) } +/// Flip a Hermes MCP server's native `enabled` field IN PLACE (true/false), +/// leaving the rest of the entry (command/args/env/tools/…) untouched. This is +/// the in-place "disable" Hermes itself uses: the config stays put and only +/// `enabled` toggles — unlike HarnessKit's generic MCP disable, it never removes +/// the entry, snapshots it, or redacts secrets. +/// +/// Hermes supports a per-server `enabled: bool` (default `true`). A server with +/// `enabled: false` is skipped entirely — no connection, discovery, or tool +/// registration — while its config is retained for later reuse. +/// Docs: https://hermes-agent.nousresearch.com/docs/reference/mcp-config-reference +/// Source: https://github.com/NousResearch/hermes-agent/blob/main/hermes_cli/mcp_config.py +pub fn set_hermes_mcp_enabled( + config_path: &Path, + name: &str, + enabled: bool, +) -> Result<(), HkError> { + modify_hermes_yaml(config_path, |root| { + let servers = root + .get_mut("mcp_servers") + .and_then(|v| v.as_mapping_mut()) + .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; + let server = servers + .get_mut(name) + .and_then(|v| v.as_mapping_mut()) + .ok_or_else(|| HkError::NotFound(format!("MCP server '{name}' not found in config")))?; + server.insert("enabled".into(), serde_yaml::Value::Bool(enabled)); + Ok(()) + }) +} + /// True if a hooks-list item matches (matcher, command). fn hermes_hook_item_matches( item: &serde_yaml::Value, @@ -2959,6 +2989,48 @@ mod tests { assert_eq!(list2, vec!["calculator"], "disabling absent plugin is a no-op"); } + #[test] + fn test_set_hermes_mcp_enabled_flips_in_place_preserving_entry() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write( + &cfg, + "mcp_servers:\n github:\n command: npx\n args:\n - -y\n env:\n TOKEN: secret123\n tools:\n include:\n - a\n - b\n enabled: true\n time:\n command: uvx\n", + ) + .unwrap(); + // disable github in place + set_hermes_mcp_enabled(&cfg, "github", false).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + let gh = doc.get("mcp_servers").and_then(|m| m.get("github")).unwrap(); + assert_eq!(gh.get("enabled").and_then(|v| v.as_bool()), Some(false)); + assert_eq!(gh.get("env").and_then(|e| e.get("TOKEN")).and_then(|v| v.as_str()), Some("secret123")); + let include: Vec<&str> = gh["tools"]["include"].as_sequence().unwrap() + .iter().filter_map(|v| v.as_str()).collect(); + assert_eq!(include, vec!["a", "b"]); + assert!(doc.get("mcp_servers").and_then(|m| m.get("time")).is_some()); + // re-enable + set_hermes_mcp_enabled(&cfg, "github", true).unwrap(); + let doc2: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(doc2["mcp_servers"]["github"]["enabled"].as_bool(), Some(true)); + // `time` has no `enabled` key on disk; disabling must INSERT enabled:false. + set_hermes_mcp_enabled(&cfg, "time", false).unwrap(); + let doc3: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); + assert_eq!(doc3["mcp_servers"]["time"]["enabled"].as_bool(), Some(false)); + // and `time` keeps its command (entry not rebuilt) + assert_eq!(doc3["mcp_servers"]["time"]["command"].as_str(), Some("uvx")); + } + + #[test] + fn test_set_hermes_mcp_enabled_missing_server_errors() { + let tmp = tempfile::tempdir().unwrap(); + let cfg = tmp.path().join("config.yaml"); + std::fs::write(&cfg, "mcp_servers:\n time:\n command: uvx\n").unwrap(); + assert!(set_hermes_mcp_enabled(&cfg, "ghost", false).is_err()); + } + #[test] fn test_copy_dir_recursive_skips_symlinks() { let src_dir = TempDir::new().unwrap(); From bead6df0a10cfaff03f46b87637d10fc0a02998c Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 5 Jun 2026 16:53:27 +0300 Subject: [PATCH 09/12] feat(hermes): add supports_native_mcp_toggle capability (default false, Hermes true) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/adapter/hermes.rs | 8 ++++++++ crates/hk-core/src/adapter/mod.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/crates/hk-core/src/adapter/hermes.rs b/crates/hk-core/src/adapter/hermes.rs index 845421ee..61678051 100644 --- a/crates/hk-core/src/adapter/hermes.rs +++ b/crates/hk-core/src/adapter/hermes.rs @@ -259,6 +259,14 @@ impl AgentAdapter for HermesAdapter { .collect() } + fn supports_native_mcp_toggle(&self) -> bool { + // Hermes MCP servers carry a native `enabled: bool` (default true); + // disabling = set `enabled: false` in place (config retained), exactly + // like the `hermes mcp` CLI. No remove, no secret redaction. + // Docs: https://hermes-agent.nousresearch.com/docs/reference/mcp-config-reference + true + } + fn hook_format(&self) -> HookFormat { HookFormat::HermesYaml } diff --git a/crates/hk-core/src/adapter/mod.rs b/crates/hk-core/src/adapter/mod.rs index 6e69b673..da30ad07 100644 --- a/crates/hk-core/src/adapter/mod.rs +++ b/crates/hk-core/src/adapter/mod.rs @@ -186,6 +186,18 @@ pub trait AgentAdapter: Send + Sync { false } + /// Whether this agent's MCP config format carries a native per-server + /// `enabled: bool` that HarnessKit should toggle IN PLACE, instead of the + /// default disable (remove the entry + snapshot it in the DB + redact env). + /// + /// Agents returning `true` are dispatched to a dedicated in-place writer in + /// `manager::toggle_mcp`; their disabled state lives in the agent's own + /// config file and is read back by `read_mcp_servers`, so no DB snapshot is + /// taken. Default `false` (the remove+snapshot path). + fn supports_native_mcp_toggle(&self) -> bool { + false + } + /// Translate a hook event name from any agent's convention to this agent's convention. /// Returns None if the event has no equivalent in this agent. /// Mappings are centralized in `hook_events.rs`. @@ -447,6 +459,20 @@ mod tests { } } + #[test] + fn test_supports_native_mcp_toggle_only_hermes() { + let adapters = all_adapters(); + for a in &adapters { + let expected = a.name() == "hermes"; + assert_eq!( + a.supports_native_mcp_toggle(), + expected, + "{} supports_native_mcp_toggle should be {expected}", + a.name() + ); + } + } + #[test] fn test_skill_dir_for_category_default_is_none_except_hermes() { // The install handlers rely on this contract: only category-aware From a324ad5d52469de810a08a41a9d7dec05814906b Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 5 Jun 2026 17:09:10 +0300 Subject: [PATCH 10/12] feat(hermes): disable MCP in place via native enabled field (preserve secrets/keys, no snapshot) Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/manager.rs | 93 +++++++++++++++++++++++++++++++++++ crates/hk-core/src/scanner.rs | 10 ++-- 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/hk-core/src/manager.rs b/crates/hk-core/src/manager.rs index 272bba87..16569e72 100644 --- a/crates/hk-core/src/manager.rs +++ b/crates/hk-core/src/manager.rs @@ -175,6 +175,18 @@ fn toggle_mcp(ext: &Extension, enabled: bool, store: &Store, adapters: &[Box> = vec![Box::new( + adapter::hermes::HermesAdapter::with_home(home.to_path_buf()), + )]; + + let scanned = scanner::scan_mcp_servers(&*adapters[0]); + store.sync_extensions(&scanned).unwrap(); + let ext = store + .list_extensions(Some(ExtensionKind::Mcp), None) + .unwrap() + .into_iter() + .find(|e| e.name == "github") + .expect("github mcp scanned"); + assert!(ext.enabled); + + // DISABLE — native in-place flip. + toggle_extension_with_adapters(&store, &adapters, &ext.id, false).unwrap(); + let doc: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(hermes.join("config.yaml")).unwrap()) + .unwrap(); + let gh = &doc["mcp_servers"]["github"]; + assert_eq!(gh["enabled"].as_bool(), Some(false), "server disabled in place"); + assert_eq!( + gh["env"]["TOKEN"].as_str(), + Some("secret123"), + "secret NOT redacted" + ); + assert!( + gh["tools"]["include"].as_sequence().is_some(), + "advanced keys kept" + ); + assert!( + !store.get_extension(&ext.id).unwrap().unwrap().enabled, + "DB shows disabled" + ); + // No DB snapshot taken for native disable. + assert!( + store.get_disabled_config(&ext.id).unwrap().is_none(), + "native disable must take no DB snapshot" + ); + + // ENABLE — must NOT hit the generic get_disabled_config(NotFound) path. + toggle_extension_with_adapters(&store, &adapters, &ext.id, true).unwrap(); + let doc2: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(hermes.join("config.yaml")).unwrap()) + .unwrap(); + assert_eq!( + doc2["mcp_servers"]["github"]["enabled"].as_bool(), + Some(true) + ); + assert_eq!( + doc2["mcp_servers"]["github"]["env"]["TOKEN"].as_str(), + Some("secret123") + ); + assert!( + store.get_extension(&ext.id).unwrap().unwrap().enabled, + "DB shows enabled" + ); + } + // ----------------------------------------------------------------------- // Task 3: manifest candidate list + silent failure // ----------------------------------------------------------------------- diff --git a/crates/hk-core/src/scanner.rs b/crates/hk-core/src/scanner.rs index 48313d41..6f49a5d5 100644 --- a/crates/hk-core/src/scanner.rs +++ b/crates/hk-core/src/scanner.rs @@ -323,10 +323,12 @@ pub fn scan_mcp_servers(adapter: &dyn AgentAdapter) -> Vec { tags: vec![], pack, permissions, - // Reflect the agent's own enabled state. For 7 of 8 adapters - // this is always true (their formats lack a disable concept); - // only OpenCode's schema can produce a false here, surfacing - // user-disabled-in-config entries as visible-but-disabled + // Reflect the agent's own enabled state. Most adapters always + // report true here (their formats lack a disable concept); + // OpenCode and Hermes can report false — OpenCode from its + // schema, Hermes from a per-server `enabled: false` (its native + // in-place MCP disable, see manager::toggle_mcp) — surfacing + // those user-disabled-in-config entries as visible-but-disabled // rather than hiding them. HarnessKit's separate UI-toggled // disable flow operates orthogonally via SQLite tracking. enabled: server.enabled, From fdba93f5304582a2c85635a962876ff848c1101e Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 5 Jun 2026 17:54:19 +0300 Subject: [PATCH 11/12] style(extensions): add Hermes agent filter color (brand gold) Hermes was the only agent without a dedicated filter color, falling back to the default gray. Add --agent-hermes (amber/gold, matching Nous Research's official 'amber-on-dark' Hermes brand: #FFD700 dark / #8B6508 light) across all theme blocks + the @theme registration, and wire it into AGENT_FILTER_COLORS. Distinct from every existing agent/semantic hue. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/extensions/extension-filters.tsx | 1 + src/index.css | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index f8905c4e..afcc9199 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -42,6 +42,7 @@ const AGENT_FILTER_COLORS: Record = { copilot: "bg-agent-copilot/10 text-agent-copilot border-agent-copilot/30", windsurf: "bg-agent-windsurf/10 text-agent-windsurf border-agent-windsurf/30", opencode: "bg-agent-opencode/10 text-agent-opencode border-agent-opencode/30", + hermes: "bg-agent-hermes/10 text-agent-hermes border-agent-hermes/30", }; export function ExtensionFilters() { diff --git a/src/index.css b/src/index.css index f80ea31b..851fe335 100644 --- a/src/index.css +++ b/src/index.css @@ -96,6 +96,7 @@ --agent-copilot: oklch(0.55 0.13 296); --agent-windsurf: oklch(0.68 0.1 225); --agent-opencode: oklch(0.52 0.07 250); + --agent-hermes: oklch(0.64 0.13 90); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -198,6 +199,7 @@ --agent-copilot: oklch(0.75 0.13 296); --agent-windsurf: oklch(0.8 0.1 225); --agent-opencode: oklch(0.76 0.06 250); + --agent-hermes: oklch(0.83 0.15 90); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -294,6 +296,7 @@ --agent-copilot: oklch(0.55 0.13 296); --agent-windsurf: oklch(0.68 0.1 225); --agent-opencode: oklch(0.52 0.07 250); + --agent-hermes: oklch(0.64 0.13 90); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.65 0.15 155); --toast-success-text: oklch(0.4 0.1 155); @@ -384,6 +387,7 @@ --agent-copilot: oklch(0.75 0.13 296); --agent-windsurf: oklch(0.8 0.1 225); --agent-opencode: oklch(0.76 0.06 250); + --agent-hermes: oklch(0.83 0.15 90); --toast-success-bg: oklch(0.26 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -486,6 +490,7 @@ --agent-copilot: oklch(0.55 0.13 296); --agent-windsurf: oklch(0.68 0.1 225); --agent-opencode: oklch(0.52 0.07 250); + --agent-hermes: oklch(0.64 0.13 90); --toast-success-bg: oklch(0.94 0.06 155); --toast-success-border: oklch(0.72 0.16 155); --toast-success-text: oklch(0.4 0.1 155); @@ -582,6 +587,7 @@ --agent-copilot: oklch(0.75 0.13 296); --agent-windsurf: oklch(0.8 0.1 225); --agent-opencode: oklch(0.76 0.06 250); + --agent-hermes: oklch(0.83 0.15 90); --toast-success-bg: oklch(0.23 0.04 155); --toast-success-border: oklch(0.55 0.14 155); --toast-success-text: oklch(0.78 0.12 155); @@ -656,6 +662,7 @@ html.dark[data-theme="tiesen"][data-web="true"] { --color-agent-copilot: var(--agent-copilot); --color-agent-windsurf: var(--agent-windsurf); --color-agent-opencode: var(--agent-opencode); + --color-agent-hermes: var(--agent-hermes); --color-sidebar: var(--sidebar); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar-primary: var(--sidebar-primary); From d92d98f2aa9ba18fe0169f97ac48911ac6cc671e Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 5 Jun 2026 18:42:58 +0300 Subject: [PATCH 12/12] refactor(hermes): drop dead MCP restore/read-config arms (superseded by native toggle) Hermes MCP enable/disable now flips the native `enabled` field in place (set_hermes_mcp_enabled), so toggle_mcp's remove+snapshot+restore path is never reached for Hermes. The HermesYaml arms of restore_mcp_server and read_mcp_server_config were therefore dead; replace them with unreachable!() documenting the invariant, and drop the now-moot restore test (its behaviour is covered by test_hermes_mcp_native_disable_enable_in_place). deploy/remove HermesYaml paths are untouched (still used by install/uninstall/kits). Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/hk-core/src/deployer.rs | 76 ++++------------------------------ 1 file changed, 8 insertions(+), 68 deletions(-) diff --git a/crates/hk-core/src/deployer.rs b/crates/hk-core/src/deployer.rs index c9e443b0..312ce670 100644 --- a/crates/hk-core/src/deployer.rs +++ b/crates/hk-core/src/deployer.rs @@ -926,22 +926,10 @@ pub fn restore_mcp_server( deploy_mcp_server_toml(config_path, &mcp_entry) } McpFormat::Opencode => restore_mcp_server_opencode(config_path, server_name, entry), - McpFormat::HermesYaml => { - // Write the full saved entry back verbatim (JSON → YAML), preserving - // advanced keys (tools/sampling/headers/ssl_verify/timeout) that a - // 5-field McpServerEntry can't carry. Mirrors the JSON restore arm. - let yaml_entry: serde_yaml::Value = - serde_yaml::to_value(entry).map_err(|e| HkError::Internal(e.to_string()))?; - modify_hermes_yaml(config_path, |root| { - let servers = root - .entry("mcp_servers".into()) - .or_insert_with(|| serde_yaml::Value::Mapping(serde_yaml::Mapping::new())) - .as_mapping_mut() - .ok_or_else(|| HkError::ConfigCorrupted("mcp_servers is not a mapping".into()))?; - servers.insert(server_name.to_string().into(), yaml_entry); - Ok(()) - }) - } + McpFormat::HermesYaml => unreachable!( + "Hermes MCP uses native in-place enable/disable (set_hermes_mcp_enabled); \ + the remove+snapshot+restore path is never reached for Hermes" + ), _ => { let key = json_top_key(format); locked_modify_json(config_path, |config| { @@ -1430,23 +1418,10 @@ pub fn read_mcp_server_config( } } McpFormat::Opencode => read_mcp_server_config_opencode(config_path, server_name), - McpFormat::HermesYaml => { - let content = std::fs::read_to_string(config_path)?; - let doc: serde_yaml::Value = serde_yaml::from_str(&content) - .map_err(|e| HkError::ConfigCorrupted(format!("Failed to parse Hermes config.yaml: {e}")))?; - let Some(entry) = doc - .get("mcp_servers") - .and_then(|v| v.get(server_name)) - else { - return Ok(None); - }; - // Convert to JSON for uniform DB storage; serde_yaml → serde_json via string - let json_str = - serde_json::to_string(&entry).map_err(|e| HkError::Internal(e.to_string()))?; - let json_val: serde_json::Value = - serde_json::from_str(&json_str).map_err(|e| HkError::Internal(e.to_string()))?; - Ok(Some(json_val)) - } + McpFormat::HermesYaml => unreachable!( + "Hermes MCP uses native in-place enable/disable (set_hermes_mcp_enabled); \ + the read-config-for-snapshot path is never reached for Hermes" + ), _ => { let config = read_or_create_json(config_path)?; let key = json_top_key(format); @@ -2841,41 +2816,6 @@ mod tests { assert!(restored.is_some()); } - #[test] - fn test_hermes_mcp_restore_preserves_advanced_keys() { - let tmp = tempfile::tempdir().unwrap(); - let cfg = tmp.path().join("config.yaml"); - std::fs::write(&cfg, "mcp_servers: {}\n").unwrap(); - // Simulate the DB snapshot taken on disable: full entry incl. advanced keys. - let saved = serde_json::json!({ - "command": "npx", - "args": ["-y", "srv"], - "env": {"API_KEY": ""}, - "tools": {"include": ["a", "b"]}, - "enabled": true - }); - restore_mcp_server(&cfg, "srv", &saved, McpFormat::HermesYaml).unwrap(); - let doc: serde_yaml::Value = - serde_yaml::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap(); - let srv = doc.get("mcp_servers").and_then(|m| m.get("srv")).unwrap(); - assert_eq!(srv.get("command").and_then(|v| v.as_str()), Some("npx")); - // advanced key survives - let include: Vec<&str> = srv["tools"]["include"] - .as_sequence() - .unwrap() - .iter() - .filter_map(|v| v.as_str()) - .collect(); - assert_eq!(include, vec!["a", "b"]); - // nested env mapping round-trips through the verbatim JSON→YAML write - assert_eq!( - srv.get("env") - .and_then(|e| e.get("API_KEY")) - .and_then(|v| v.as_str()), - Some("") - ); - } - #[test] fn test_hermes_yaml_hook_deploy_dedup() { let tmp = tempfile::tempdir().unwrap();