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

+ Choose a Hermes category +

+ {hermesMarketNewMode ? ( +
+ setHermesMarketNewName(e.target.value)} + placeholder="new-category-name" + className="flex-1 rounded-lg border border-border bg-background px-2.5 py-1 text-xs outline-none focus:border-ring focus:ring-2 focus:ring-ring/50" + autoFocus + /> + +
+ ) : ( +
+ {hermesMarketCategories.map((cat) => ( + + ))} + +
+ )} +
+ + +
+
+ )} + {/* SKILL.md content (skills only) */} {selectedItem.kind === "skill" && (
diff --git a/src/stores/extension-store.ts b/src/stores/extension-store.ts index 46c3697b..cda5a27c 100644 --- a/src/stores/extension-store.ts +++ b/src/stores/extension-store.ts @@ -64,7 +64,7 @@ interface ExtensionState { updateTags: (groupKey: string, tags: string[]) => Promise; updatePack: (groupKey: string, pack: string | null) => Promise; fetchPacks: () => Promise; - installToAgent: (id: string, targetAgent: string) => Promise; + installToAgent: (id: string, targetAgent: string, hermesCategory?: string) => Promise; toggle: (groupKey: string, enabled: boolean) => Promise; batchToggle: (enabled: boolean) => Promise; undoDelete: () => void; @@ -261,8 +261,8 @@ export const useExtensionStore = create((set, get) => ({ }); }, - async installToAgent(id, targetAgent) { - await api.installToAgent(id, targetAgent); + async installToAgent(id, targetAgent, hermesCategory) { + await api.installToAgent(id, targetAgent, hermesCategory); await get().rescanAndFetch(); }, diff --git a/src/stores/marketplace-store.ts b/src/stores/marketplace-store.ts index ced56000..baa4e7d6 100644 --- a/src/stores/marketplace-store.ts +++ b/src/stores/marketplace-store.ts @@ -44,6 +44,7 @@ interface MarketplaceState { item: MarketplaceItem, targetAgent: string | undefined, targetScope: ConfigScope, + hermesCategory?: string, ) => Promise; } @@ -437,7 +438,7 @@ export const useMarketplaceStore = create((set, get) => ({ cliReadme: null, }); }, - async install(item, targetAgent, targetScope) { + async install(item, targetAgent, targetScope, hermesCategory) { set({ installing: `${item.id}:${targetAgent ?? ""}` }); try { let { source, skill_id } = item; @@ -464,6 +465,7 @@ export const useMarketplaceStore = create((set, get) => ({ skill_id, targetAgent, targetScope, + hermesCategory, ); set({ installing: null }); return result;