diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8f52835d78..1fe36a5666 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2118,18 +2118,65 @@ class ProfileSoulUpdate(BaseModel): content: str +def _profile_attr(info, name: str, default: Any = None) -> Any: + try: + return getattr(info, name) + except Exception: + return default + + def _profile_to_dict(info) -> Dict[str, Any]: return { - "name": info.name, - "path": str(info.path), - "is_default": info.is_default, - "model": info.model, - "provider": info.provider, - "has_env": info.has_env, - "skill_count": info.skill_count, + "name": _profile_attr(info, "name", ""), + "path": str(_profile_attr(info, "path", "")), + "is_default": bool(_profile_attr(info, "is_default", False)), + "model": _profile_attr(info, "model"), + "provider": _profile_attr(info, "provider"), + "has_env": bool(_profile_attr(info, "has_env", False)), + "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), } +def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: + def _safe(callable_, default): + try: + return callable_() + except Exception: + return default + + profiles: List[Dict[str, Any]] = [] + default_home = profiles_mod._get_default_hermes_home() + if default_home.is_dir(): + model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) + profiles.append({ + "name": "default", + "path": str(default_home), + "is_default": True, + "model": model, + "provider": provider, + "has_env": (default_home / ".env").exists(), + "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), + }) + + profiles_root = profiles_mod._get_profiles_root() + if profiles_root.is_dir(): + for entry in sorted(profiles_root.iterdir()): + if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): + continue + model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None)) + profiles.append({ + "name": entry.name, + "path": str(entry), + "is_default": False, + "model": model, + "provider": provider, + "has_env": (entry / ".env").exists(), + "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), + }) + + return profiles + + def _resolve_profile_dir(name: str) -> Path: """Validate ``name`` and resolve to its directory or raise an HTTPException.""" from hermes_cli import profiles as profiles_mod @@ -2145,7 +2192,11 @@ def _resolve_profile_dir(name: str) -> Path: @app.get("/api/profiles") async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod - return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} + try: + return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} + except Exception: + _log.exception("GET /api/profiles failed; falling back to profile directory scan") + return {"profiles": _fallback_profile_dicts(profiles_mod)} @app.post("/api/profiles") diff --git a/tests/hermes_cli/test_dashboard_profiles_nav_label.py b/tests/hermes_cli/test_dashboard_profiles_nav_label.py new file mode 100644 index 0000000000..583e62ee9f --- /dev/null +++ b/tests/hermes_cli/test_dashboard_profiles_nav_label.py @@ -0,0 +1,11 @@ +"""Static dashboard tests for the Profiles navigation copy.""" +from pathlib import Path + + +def test_profiles_nav_label_uses_short_multi_agents_copy(): + en_i18n = Path(__file__).resolve().parents[2] / "web" / "src" / "i18n" / "en.ts" + + content = en_i18n.read_text(encoding="utf-8") + + assert 'profiles: "profiles : multi agents"' in content + assert "Profiles: Running Multiple Agents" not in content diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b090c5f23d..09ed088036 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -596,6 +596,37 @@ class TestNewEndpoints: names = [p["name"] for p in resp.json()["profiles"]] assert "default" in names + def test_profiles_list_falls_back_when_profile_listing_fails(self, monkeypatch): + from hermes_constants import get_hermes_home + import hermes_cli.profiles as profiles_mod + + hermes_home = get_hermes_home() + hermes_home.mkdir(parents=True, exist_ok=True) + (hermes_home / "config.yaml").write_text( + "model:\n provider: openrouter\n name: anthropic/claude-sonnet-4.6\n", + encoding="utf-8", + ) + named = hermes_home / "profiles" / "multi-agent" + named.mkdir(parents=True) + (named / ".env").write_text("EXAMPLE=1\n", encoding="utf-8") + (named / "skills" / "demo").mkdir(parents=True) + (named / "skills" / "demo" / "SKILL.md").write_text("---\nname: demo\n---\n", encoding="utf-8") + + monkeypatch.setattr( + profiles_mod, + "list_profiles", + lambda: (_ for _ in ()).throw(RuntimeError("boom")), + ) + + resp = self.client.get("/api/profiles") + + assert resp.status_code == 200 + profiles = {p["name"]: p for p in resp.json()["profiles"]} + assert profiles["default"]["is_default"] is True + assert profiles["default"]["provider"] == "openrouter" + assert profiles["multi-agent"]["has_env"] is True + assert profiles["multi-agent"]["skill_count"] == 1 + def test_profiles_create_rename_delete_round_trip(self, monkeypatch): # Stub gateway service teardown so the test doesn't shell out to # launchctl/systemctl on the host. diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f3974c1ee2..ea31e4abf6 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -74,7 +74,7 @@ export const en: Translations = { documentation: "Documentation", keys: "Keys", logs: "Logs", - profiles: "Profiles: Running Multiple Agents", + profiles: "profiles : multi agents", sessions: "Sessions", skills: "Skills", },