diff --git a/crates/hk-core/src/service.rs b/crates/hk-core/src/service.rs index 88d494b9..fee69dfe 100644 --- a/crates/hk-core/src/service.rs +++ b/crates/hk-core/src/service.rs @@ -917,6 +917,19 @@ pub fn delete_extension( eprintln!("Warning: failed to clean up VS Code plugin entry: {e}"); } } + } else if adapter.name() == "hermes" { + // Hermes: remove the plugin directory AND drop its name from + // config.yaml `plugins.enabled`, so no stale enabled entry is left + // behind (a re-install of the same name would otherwise be auto-enabled). + // Mirrors how codex/gemini remove both the folder and the config entry. + if let Some(ref path) = plugin.path { + remove_path(path)?; + } + deployer::set_hermes_plugin_enabled( + &adapter.plugin_config_path(), + &plugin.name, + false, + )?; } else if let Some(ref path) = plugin.path { remove_path(path)?; } @@ -2412,4 +2425,86 @@ mod tests { .is_none() ); } + + /// Deleting an *enabled* Hermes plugin must remove BOTH its on-disk + /// directory AND its name from `config.yaml` `plugins.enabled`. Before the + /// dedicated hermes arm, Hermes fell into the generic fallback which only + /// removed the directory, leaving a stale `plugins.enabled` entry behind + /// (a re-install of the same name would then be auto-enabled). + #[test] + fn test_delete_extension_removes_hermes_plugin_dir_and_enabled_entry() { + use crate::adapter; + + let dir = TempDir::new().unwrap(); + let home = dir.path(); + + // On-disk Hermes layout: ~/.hermes/plugins/weather/plugin.yaml plus a + // config.yaml that lists "weather" under plugins.enabled (so the + // scanned extension comes back enabled). + let plugin_dir = home.join(".hermes").join("plugins").join("weather"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + std::fs::write( + plugin_dir.join("plugin.yaml"), + "name: weather\nversion: 1.0.0\n", + ) + .unwrap(); + let config_path = home.join(".hermes").join("config.yaml"); + std::fs::write(&config_path, "plugins:\n enabled:\n - weather\n").unwrap(); + + let store_raw = Store::open(&home.join("test.db")).unwrap(); + let store = Mutex::new(store_raw); + + let adapters: Vec> = + vec![Box::new(adapter::hermes::HermesAdapter::with_home( + home.to_path_buf(), + ))]; + + // Scan + sync so the plugin extension lands in the store with an id. + let exts = scanner::scan_all(&adapters, &[]); + store.lock().sync_extensions(&exts).unwrap(); + + let all = store.lock().list_extensions(None, None).unwrap(); + let plugin = all + .iter() + .find(|e| e.kind == ExtensionKind::Plugin && e.name == "weather") + .expect("scanned hermes plugin should be in the store"); + assert!(plugin.enabled, "weather plugin should scan as enabled"); + let id = plugin.id.clone(); + + // Sanity precondition: config.yaml currently lists "weather". + let pre: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); + let pre_enabled = pre + .get("plugins") + .and_then(|p| p.get("enabled")) + .and_then(|e| e.as_sequence()) + .cloned() + .unwrap_or_default(); + assert!( + pre_enabled.iter().any(|v| v.as_str() == Some("weather")), + "precondition: weather should be in plugins.enabled before delete" + ); + + delete_extension(&store, &adapters, &id).unwrap(); + + // Assertion 1: the plugin directory is gone. + assert!( + !plugin_dir.exists(), + "plugin directory should be removed after delete" + ); + + // Assertion 2: "weather" no longer appears in plugins.enabled. + let post: serde_yaml::Value = + serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); + let post_enabled = post + .get("plugins") + .and_then(|p| p.get("enabled")) + .and_then(|e| e.as_sequence()) + .cloned() + .unwrap_or_default(); + assert!( + !post_enabled.iter().any(|v| v.as_str() == Some("weather")), + "weather should be removed from plugins.enabled after delete" + ); + } } diff --git a/media/agents-animation.gif b/media/agents-animation.gif index ca07b76a..197908db 100644 Binary files a/media/agents-animation.gif and b/media/agents-animation.gif differ diff --git a/media/overview.png b/media/overview.png index 83356de4..0187c8d0 100644 Binary files a/media/overview.png and b/media/overview.png differ diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 9a9fb63f..a61b289d 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -605,7 +605,7 @@ export function ExtensionDetail() { {hermesCategoryPicker && (

- Choose a Hermes category + {tc("hermesCategory.choose")}

) : null} - Install to Hermes + {tc("hermesCategory.install")}
diff --git a/src/components/shared/agent-mascot/hermes-mascot.tsx b/src/components/shared/agent-mascot/hermes-mascot.tsx index 6a163764..7f181e24 100644 --- a/src/components/shared/agent-mascot/hermes-mascot.tsx +++ b/src/components/shared/agent-mascot/hermes-mascot.tsx @@ -15,12 +15,51 @@ export function HermesMascot({ size }: MascotSvgProps) { className="hermes-svg" style={{ color: "var(--mascot-icon-color)", overflow: "visible" }} > + + {/* Specular band: a highlight that sweeps across on hover. Colour comes + from --hermes-gleam (white on the dark light-theme silhouette, warm + gold on the white dark-theme silhouette). The 0.4–0.6 plateau gives + the highlight a soft, wide core rather than a single bright line. */} + + + + + + + {/* Clip the gleam to the head silhouette so the light only catches the + figure and never bleeds past the icon onto the card. The big body + path (path 3) is the dominant shape; the two tiny detail paths are + negligible, so we clip to the body alone. Its `d` is duplicated here + (rather than referenced via ) because WebKit — the Tauri + webview — does not reliably honour inside . */} + + + + + {/* Light glint: a soft specular band, clipped to the silhouette, swept + diagonally across on hover (see .hermes-gleam in mascot.css). The clip + keeps it inside the figure so it never bleeds onto the card. */} + + + + + ); } diff --git a/src/components/shared/agent-mascot/mascot.css b/src/components/shared/agent-mascot/mascot.css index b5a72b8a..3717a3d4 100644 --- a/src/components/shared/agent-mascot/mascot.css +++ b/src/components/shared/agent-mascot/mascot.css @@ -5,11 +5,17 @@ :root { --mascot-icon-color: #000000; --mascot-bg-color: #ffffff; + /* Hermes glint: white specular on the dark (light-theme) silhouette. */ + --hermes-gleam: #ffffff; } .dark { --mascot-icon-color: #ffffff; --mascot-bg-color: #000000; + /* On the white (dark-theme) silhouette a white glint would vanish, so the + light reads as a warm gold gleam instead — kept pale so it stays a soft + glint rather than a heavy gold band. */ + --hermes-gleam: #fceecb; } /* === Claude === */ @@ -1271,12 +1277,39 @@ 100% { opacity: 0; transform: translate(0, 0) rotate(0deg); } } -/* === Hermes === */ +/* === Hermes === + The icon is a classical left-facing profile — a coin/cameo head. So the + motion leans into that: hover catches a glint of light across the profile + (like a minted coin), and click flips the whole face like a tossed coin. + The flip's perspective is baked into the transform (perspective()) rather + than set on an ancestor, since the wrapper divs aren't preserve-3d. */ + +/* The gleam band's colour + softness; transparent edges, bright core. */ +.mascot-hermes .hermes-gleam-edge { + stop-color: var(--hermes-gleam); + stop-opacity: 0; +} +.mascot-hermes .hermes-gleam-core { + stop-color: var(--hermes-gleam); + stop-opacity: 0.85; +} +/* At rest the band sits off to the left of the figure (x = -15), so the clip + hides it; the sweep only runs while hovered. */ +.mascot-hermes .hermes-gleam { + transform: translateX(0); +} + +/* Hover: gentle float for life + the glint sweeping across. */ .mascot-hermes.is-animated .hermes-svg { - animation: hermes-float 2.2s ease-in-out infinite; + animation: hermes-float 2.6s ease-in-out infinite; +} +.mascot-hermes.is-animated .hermes-gleam { + animation: hermes-sheen 4s ease-in-out infinite; } + +/* Click: toss-and-flip like a coin. */ .mascot-hermes.is-clicked .hermes-svg { - animation: hermes-spin 0.6s cubic-bezier(0.34, 1.56, 0.64, 1); + animation: hermes-coinflip 0.8s cubic-bezier(0.3, 0.7, 0.2, 1); } @keyframes hermes-float { @@ -1284,22 +1317,42 @@ 100% { transform: translateY(0) scale(1); } - 40% { - transform: translateY(-5px) scale(1.04); + 45% { + transform: translateY(-4px) scale(1.03); } 70% { - transform: translateY(-3px) scale(1.02); + transform: translateY(-2px) scale(1.01); } } -@keyframes hermes-spin { +/* Band travels left → right across the profile (units are SVG user units, so + ~46 carries the x=-15 band fully clear of the 24-wide figure). It then + teleports back to the start while still off-figure (both ends are clipped, + so no visible rewind) and holds for the rest of the cycle. */ +@keyframes hermes-sheen { 0% { - transform: rotate(0deg) scale(1); + transform: translateX(0); } - 40% { - transform: rotate(200deg) scale(1.12); + 42% { + transform: translateX(46px); + } + 42.01% { + transform: translateX(0); } 100% { - transform: rotate(360deg) scale(1); + transform: translateX(0); + } +} +/* Two full turns with a small lift at the apex sells the toss; rotateY + + perspective foreshortens the profile through the spin like a coin's edge. */ +@keyframes hermes-coinflip { + 0% { + transform: perspective(600px) translateY(0) rotateY(0deg); + } + 45% { + transform: perspective(600px) translateY(-4px) rotateY(396deg); + } + 100% { + transform: perspective(600px) translateY(0) rotateY(720deg); } } diff --git a/src/components/shared/hermes-category-picker.tsx b/src/components/shared/hermes-category-picker.tsx index e3b03a99..88a2c362 100644 --- a/src/components/shared/hermes-category-picker.tsx +++ b/src/components/shared/hermes-category-picker.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useTranslation } from "react-i18next"; interface HermesCategoryPickerProps { /** Existing category names under `~/.hermes/skills/`. */ @@ -24,6 +25,7 @@ export function HermesCategoryPicker({ onChange, disabled, }: HermesCategoryPickerProps) { + const { t } = useTranslation("common"); const [newMode, setNewMode] = useState(false); if (newMode) { @@ -33,7 +35,7 @@ export function HermesCategoryPicker({ type="text" value={value} onChange={(e) => onChange(e.target.value)} - placeholder="new-category-name" + placeholder={t("hermesCategory.newPlaceholder")} 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. @@ -48,7 +50,7 @@ export function HermesCategoryPicker({ disabled={disabled} className="text-xs text-muted-foreground hover:text-foreground" > - Cancel + {t("cancel")} ); @@ -80,7 +82,7 @@ export function HermesCategoryPicker({ disabled={disabled} className="rounded-full px-2.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground hover:bg-muted/80 transition-colors" > - + New + {t("hermesCategory.addNew")} ); diff --git a/src/lib/i18n/locales/en/common.json b/src/lib/i18n/locales/en/common.json index ef5c9444..0509f92d 100644 --- a/src/lib/i18n/locales/en/common.json +++ b/src/lib/i18n/locales/en/common.json @@ -67,6 +67,12 @@ "hook": "hook", "cli": "CLI" }, + "hermesCategory": { + "choose": "Choose a Hermes category", + "install": "Install to Hermes", + "addNew": "+ New", + "newPlaceholder": "new-category-name" + }, "tag": "Tag", "tags": "Tags", "pack": "Pack", diff --git a/src/lib/i18n/locales/zh/common.json b/src/lib/i18n/locales/zh/common.json index b726d49a..cd7d62b5 100644 --- a/src/lib/i18n/locales/zh/common.json +++ b/src/lib/i18n/locales/zh/common.json @@ -67,6 +67,12 @@ "hook": "hook", "cli": "CLI" }, + "hermesCategory": { + "choose": "选择 Hermes 分类", + "install": "安装到 Hermes", + "addNew": "+ 新建", + "newPlaceholder": "新分类名称" + }, "tag": "标签", "tags": "标签", "pack": "套装", diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index e26b0743..ac86954c 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -215,6 +215,7 @@ function ItemRow({ export default function MarketplacePage() { const { t } = useTranslation("marketplace"); + const { t: tc } = useTranslation("common"); const { tab, setTab, @@ -884,7 +885,7 @@ export default function MarketplacePage() { {hermesPending && hermesPending.item.id === selectedItem.id && (

- Choose a Hermes category + {tc("hermesCategory.choose")}

) : null} - Install to Hermes + {tc("hermesCategory.install")}