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..61678051 --- /dev/null +++ b/crates/hk-core/src/adapter/hermes.rs @@ -0,0 +1,655 @@ +// 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: ~/.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/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, PluginEntry, 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 + } + + fn parse_yaml(path: &Path) -> Option { + 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 { + 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 { + // 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 { + 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 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 + } + + fn hook_config_path(&self) -> PathBuf { + // Hermes hooks live at the root `hooks:` key of config.yaml. + self.base_dir().join("config.yaml") + } + + fn read_hooks(&self) -> 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 { + 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 { + self.list_categories() + } + + /// Hermes organises skills into named category subdirectories. Resolve the + /// 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 { + match scope { + crate::models::ConfigScope::Global => { + Some(self.base_dir().join("skills").join(category)) + } + // Global-only: no project-level skills (hermes-agent#4667). + crate::models::ConfigScope::Project { .. } => None, + } + } + + // --- 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 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 { + 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_empty_global_only() { + // Hermes has no project-level skill concept (docs + hermes-agent#4667). + let adapter = HermesAdapter::new(); + assert!(adapter.project_skill_dirs().is_empty()); + } + + #[test] + fn test_read_hooks_empty() { + 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(); + 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()); + } + + #[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_global_only() { + 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")); + + // 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/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 d5500ee3..da30ad07 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; @@ -92,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, } @@ -125,6 +130,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 { @@ -177,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`. @@ -307,6 +328,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` @@ -345,6 +373,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 { @@ -370,6 +410,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 +419,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 +431,7 @@ mod tests { assert!(names.contains(&"copilot")); assert!(names.contains(&"windsurf")); assert!(names.contains(&"opencode")); + assert!(names.contains(&"hermes")); } #[test] @@ -409,7 +451,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" @@ -417,6 +459,40 @@ 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 + // 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(); @@ -481,6 +557,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)", @@ -503,10 +582,14 @@ mod tests { ("antigravity", ".agents/skills"), // 1.18.4+ canonical; .agent/ kept as backward-compat alias ("copilot", ".github/skills"), ("opencode", ".opencode/skills"), + // 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/deployer.rs b/crates/hk-core/src/deployer.rs index a2105869..312ce670 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,257 @@ fn deploy_mcp_server_opencode(config_path: &Path, entry: &McpServerEntry) -> Res }) } +/// 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. +/// +/// 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, + f: impl FnOnce(&mut serde_yaml::Mapping) -> Result<(), HkError>, +) -> Result<(), HkError> { + 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.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(()) +} + +/// 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("enabled".into(), serde_yaml::Value::Bool(true)); + servers.insert(entry.name.clone().into(), serde_yaml::Value::Mapping(server)); + Ok(()) + }) +} + +/// 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(()) + }) +} + +/// 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, + 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 @@ -324,6 +579,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 => { @@ -438,6 +696,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())); } @@ -474,6 +736,12 @@ pub fn remove_mcp_server( Ok(()) } McpFormat::Opencode => remove_mcp_server_opencode(config_path, server_name), + 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); + } + 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()) { @@ -511,6 +779,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(()); } @@ -584,6 +855,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())); } @@ -651,6 +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 => 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| { @@ -698,6 +977,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 => { @@ -753,6 +1035,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())); } @@ -1132,6 +1418,10 @@ pub fn read_mcp_server_config( } } McpFormat::Opencode => read_mcp_server_config_opencode(config_path, server_name), + 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); @@ -1176,6 +1466,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); } @@ -1233,6 +1526,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), } } @@ -2465,6 +2760,217 @@ 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_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_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(); 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/manager.rs b/crates/hk-core/src/manager.rs index 72f59c12..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 2c2139c2..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, @@ -2327,6 +2329,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/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index d0f3f7ed..88d494b9 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,10 +1174,14 @@ 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() + // 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)) })?; @@ -1768,7 +1773,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 +1867,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 +1981,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..a4664ce4 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,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 = a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent_name, target_scope - )) - })?; + // 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)?; } @@ -595,11 +618,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..56096978 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,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 = a.skill_dir_for(&target_scope).ok_or_else(|| { - HkError::Internal(format!( - "Agent '{}' has no skill directory for scope {:?}", - agent, target_scope - )) - })?; + 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-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..6ffe1075 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,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 = 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 = 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()) @@ -152,6 +158,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 +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 = 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 = 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)?; } @@ -233,6 +245,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 +275,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 +906,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..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"; @@ -74,6 +75,10 @@ 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 [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); const [deleteAgents, setDeleteAgents] = useState>(new Set()); @@ -130,6 +135,37 @@ 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 +515,8 @@ export function ExtensionDetail() { const hookUnsupported = group.kind === "hook" && AGENTS_WITHOUT_HOOKS.has(agent.name); + const isHermes = + agent.name === "hermes" && group.kind === "skill"; return ( + + + + )} ); })()} 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/components/extensions/install-dialog.tsx b/src/components/extensions/install-dialog.tsx index 001410c2..4147cc6e 100644 --- a/src/components/extensions/install-dialog.tsx +++ b/src/components/extensions/install-dialog.tsx @@ -2,9 +2,11 @@ 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"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import { openDirectoryPicker } from "@/lib/dialog"; import { humanizeError } from "@/lib/errors"; import { api } from "@/lib/invoke"; @@ -28,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); @@ -38,6 +42,8 @@ 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 fetch = useExtensionStore((s) => s.fetch); const { agents, fetch: fetchAgents, agentOrder } = useAgentStore(); const { scope } = useScope(); @@ -59,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; @@ -68,6 +84,23 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { } }, [singleAgentName]); + const hermesSelected = selectedAgents.has("hermes"); + + // 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(() => {}); + }, [hermesSelected]); + // Reset form when closing useEffect(() => { if (!open) { @@ -77,12 +110,26 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setDiscoveredSkills([]); setSelectedSkills(new Set()); setCloneId(null); + setHermesCategory("local"); setInstallTargetScope( scope.type === "all" ? null : (scope as ConfigScope), ); } }, [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(); @@ -103,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))); } }; @@ -148,11 +198,13 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { setLoading(true); setError(null); try { + const effectiveHermesCategory = hermesCategory.trim() || "local"; if (mode === "local") { const result = await api.installFromLocal( source.trim(), [...selectedAgents], installTargetScope, + hermesSelected ? effectiveHermesCategory : undefined, ); await fetch(); onClose(); @@ -306,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 ( + + ); + })} )} @@ -330,6 +402,23 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { onChange={setInstallTargetScope} /> + + {/* Hermes category picker — only when Hermes is a selected target */} + {hermesSelected && ( +
+ + Hermes category + +
+ +
+
+ )} ) : ( <> 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..6a163764 --- /dev/null +++ b/src/components/shared/agent-mascot/hermes-mascot.tsx @@ -0,0 +1,26 @@ +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/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/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); 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. 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..e26b0743 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -21,12 +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 { humanizeError } from "@/lib/errors"; +import { api } from "@/lib/invoke"; import { agentDisplayName, type ConfigScope, @@ -251,6 +253,15 @@ 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< + string[] + >([]); + const [hermesMarketCategory, setHermesMarketCategory] = useState("local"); const detailPanelRef = useRef(null); const isItemInstalled = ( @@ -347,10 +358,24 @@ 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"); + 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 +880,53 @@ export default function MarketplacePage() { )} + {/* Hermes category picker — shown when Hermes is clicked */} + {hermesPending && hermesPending.item.id === selectedItem.id && ( +
+

+ Choose a Hermes category +

+ +
+ + +
+
+ )} + {/* SKILL.md content (skills only) */} {selectedItem.kind === "skill" && (
diff --git a/src/stores/extension-store.ts b/src/stores/extension-store.ts index 46c3697b..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) => Promise; + installToAgent: ( + id: string, + targetAgent: string, + hermesCategory?: string, + ) => Promise; toggle: (groupKey: string, enabled: boolean) => Promise; batchToggle: (enabled: boolean) => Promise; undoDelete: () => void; @@ -261,8 +265,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;