Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions crates/hk-core/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
}
Expand Down Expand Up @@ -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<Box<dyn adapter::AgentAdapter>> =
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"
);
}
}
Binary file modified media/agents-animation.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified media/overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions src/components/extensions/extension-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ export function ExtensionDetail() {
{hermesCategoryPicker && (
<div className="mt-2 rounded-lg border border-border bg-muted/20 p-3">
<p className="mb-2 text-xs font-medium text-foreground">
Choose a Hermes category
{tc("hermesCategory.choose")}
</p>
<HermesCategoryPicker
categories={hermesCategories}
Expand Down Expand Up @@ -651,13 +651,13 @@ export function ExtensionDetail() {
className="animate-spin inline mr-1"
/>
) : null}
Install to Hermes
{tc("hermesCategory.install")}
</button>
<button
onClick={() => setHermesCategoryPicker(false)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
{tc("cancel")}
</button>
</div>
</div>
Expand Down
39 changes: 39 additions & 0 deletions src/components/shared/agent-mascot/hermes-mascot.tsx

Large diffs are not rendered by default.

75 changes: 64 additions & 11 deletions src/components/shared/agent-mascot/mascot.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 === */
Expand Down Expand Up @@ -1271,35 +1277,82 @@
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 {
0%,
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);
}
}

Expand Down
8 changes: 5 additions & 3 deletions src/components/shared/hermes-category-picker.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";

interface HermesCategoryPickerProps {
/** Existing category names under `~/.hermes/skills/`. */
Expand All @@ -24,6 +25,7 @@ export function HermesCategoryPicker({
onChange,
disabled,
}: HermesCategoryPickerProps) {
const { t } = useTranslation("common");
const [newMode, setNewMode] = useState(false);

if (newMode) {
Expand All @@ -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.
Expand All @@ -48,7 +50,7 @@ export function HermesCategoryPicker({
disabled={disabled}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
{t("cancel")}
</button>
</div>
);
Expand Down Expand Up @@ -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")}
</button>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions src/lib/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions src/lib/i18n/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@
"hook": "hook",
"cli": "CLI"
},
"hermesCategory": {
"choose": "选择 Hermes 分类",
"install": "安装到 Hermes",
"addNew": "+ 新建",
"newPlaceholder": "新分类名称"
},
"tag": "标签",
"tags": "标签",
"pack": "套装",
Expand Down
7 changes: 4 additions & 3 deletions src/pages/marketplace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,7 @@ function ItemRow({

export default function MarketplacePage() {
const { t } = useTranslation("marketplace");
const { t: tc } = useTranslation("common");
const {
tab,
setTab,
Expand Down Expand Up @@ -884,7 +885,7 @@ export default function MarketplacePage() {
{hermesPending && hermesPending.item.id === selectedItem.id && (
<div className="mt-3 rounded-lg border border-border bg-muted/20 p-3">
<p className="mb-2 text-xs font-medium text-foreground">
Choose a Hermes category
{tc("hermesCategory.choose")}
</p>
<HermesCategoryPicker
categories={hermesMarketCategories}
Expand Down Expand Up @@ -915,13 +916,13 @@ export default function MarketplacePage() {
className="animate-spin inline mr-1"
/>
) : null}
Install to Hermes
{tc("hermesCategory.install")}
</button>
<button
onClick={() => setHermesPending(null)}
className="text-xs text-muted-foreground hover:text-foreground"
>
Cancel
{tc("cancel")}
</button>
</div>
</div>
Expand Down
Loading