diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 0acb41d6878..f4d894c1e7a 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -666,25 +666,46 @@ def _load_skin_from_yaml(path: Path) -> Optional[Dict[str, Any]]: return None +def _mapping_or_empty(value: Any, *, section: str, skin_name: str) -> Dict[str, Any]: + """Return a mapping value or an empty dict when the section type is invalid.""" + if isinstance(value, dict): + return value + if value is None: + return {} + logger.warning( + "Skin '%s' has invalid '%s' section type (%s); ignoring section", + skin_name, + section, + type(value).__name__, + ) + return {} + + def _build_skin_config(data: Dict[str, Any]) -> SkinConfig: """Build a SkinConfig from a raw dict (built-in or loaded from YAML).""" # Start with default values as base for missing keys default = _BUILTIN_SKINS["default"] + skin_name = str(data.get("name", "unknown")) + color_overrides = _mapping_or_empty(data.get("colors"), section="colors", skin_name=skin_name) + spinner_overrides = _mapping_or_empty(data.get("spinner"), section="spinner", skin_name=skin_name) + branding_overrides = _mapping_or_empty(data.get("branding"), section="branding", skin_name=skin_name) + emoji_overrides = _mapping_or_empty(data.get("tool_emojis"), section="tool_emojis", skin_name=skin_name) + colors = dict(default.get("colors", {})) - colors.update(data.get("colors", {})) + colors.update(color_overrides) spinner = dict(default.get("spinner", {})) - spinner.update(data.get("spinner", {})) + spinner.update(spinner_overrides) branding = dict(default.get("branding", {})) - branding.update(data.get("branding", {})) + branding.update(branding_overrides) return SkinConfig( - name=data.get("name", "unknown"), + name=skin_name, description=data.get("description", ""), colors=colors, spinner=spinner, branding=branding, tool_prefix=data.get("tool_prefix", default.get("tool_prefix", "┊")), - tool_emojis=data.get("tool_emojis", {}), + tool_emojis=emoji_overrides, banner_logo=data.get("banner_logo", ""), banner_hero=data.get("banner_hero", ""), ) diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 6c23824b9e5..9da6df2bc28 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -199,6 +199,37 @@ class TestUserSkins: # Should inherit defaults for unspecified colors assert skin.get_color("banner_border") == "#CD7F32" # from default + def test_load_user_skin_invalid_section_types_fall_back_to_defaults(self, tmp_path, monkeypatch): + from hermes_cli.skin_engine import load_skin + + skins_dir = tmp_path / "skins" + skins_dir.mkdir() + import yaml + + (skins_dir / "broken.yaml").write_text( + yaml.dump( + { + "name": "broken", + "colors": ["not", "a", "mapping"], + "spinner": "invalid", + "branding": ["also", "invalid"], + "tool_emojis": ["invalid"], + "tool_prefix": "!", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr("hermes_cli.skin_engine._skins_dir", lambda: skins_dir) + + skin = load_skin("broken") + + assert skin.name == "broken" + assert skin.get_color("banner_title") == "#FFD700" + assert skin.get_branding("agent_name") == "Hermes Agent" + assert skin.get_spinner_list("waiting_faces") == [] + assert skin.tool_emojis == {} + assert skin.tool_prefix == "!" + def test_list_skins_includes_user_skins(self, tmp_path, monkeypatch): from hermes_cli.skin_engine import list_skins skins_dir = tmp_path / "skins"