From e5cb020a7db6608566b71d82932eae24a857d180 Mon Sep 17 00:00:00 2001 From: cypres0099 Date: Wed, 13 May 2026 11:38:33 -0500 Subject: [PATCH 1/4] feat(dashboard): per-profile skills toggle UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a profile selector to the dashboard's Skills page so each installed profile's skills.disabled list can be managed from the same dashboard daemon. Until now, /api/skills only knew the active profile (whichever HERMES_HOME the dashboard process was launched under), so toggling skills for a non-active profile required spinning up a second dashboard daemon bound to that profile's HERMES_HOME — operationally awkward for users running multiple profiles (e.g. a default + a worker/specialist profile). Backend ------- - Add GET /api/profiles/{name}/skills — list a profile's skills - Add PUT /api/profiles/{name}/skills/toggle — toggle for one profile - Add is_active to ProfileInfo so the UI can identify the daemon's resident profile (the one served by the legacy /api/skills routes). - Reads/writes go directly against the profile's config.yaml (skills.disabled). The load_config/save_config helpers are bound to the process-level HERMES_HOME via get_config_path(), so they can't be reused for cross-profile mutation without invasive global state changes. - v1 omits skills.external_dirs scanning for non-active profiles. The dropdown targets profile-installed skills; external dirs are still respected by the gateway at runtime. Frontend -------- - SkillsPage gains a Select dropdown next to the enabled-of count (hidden when there's only one installed profile, so default-only installs are unchanged). - Default selection is the dashboard's own profile (is_active, or is_default for older gateways that don't emit the field). - Switching profile refetches the skills list from the profile-scoped endpoint; the active-profile selection still uses the legacy /api/skills route to stay in sync with the gateway's skill index. - Toggles route through the appropriate endpoint based on selection. The legacy /api/skills and /api/skills/toggle routes are untouched and remain the canonical path for the active profile. --- hermes_cli/web_server.py | 179 +++++++++++++++++++++++++++- tests/hermes_cli/test_web_server.py | 104 ++++++++++++++++ web/src/lib/api.ts | 21 ++++ web/src/pages/SkillsPage.tsx | 97 ++++++++++++++- 4 files changed, 394 insertions(+), 7 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 3f0eae0aebc25..57d3e6a00c4cf 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2657,11 +2657,26 @@ def _profile_attr(info, name: str, default: Any = None) -> Any: return default +def _active_profile_path_str() -> str: + """Resolved HERMES_HOME of the running dashboard process, for is_active comparison.""" + from hermes_constants import get_hermes_home + try: + return str(get_hermes_home().resolve()) + except Exception: + return "" + + def _profile_to_dict(info) -> Dict[str, Any]: + path_str = str(_profile_attr(info, "path", "")) + try: + resolved = str(Path(path_str).resolve()) if path_str else "" + except Exception: + resolved = path_str return { "name": _profile_attr(info, "name", ""), - "path": str(_profile_attr(info, "path", "")), + "path": path_str, "is_default": bool(_profile_attr(info, "is_default", False)), + "is_active": bool(resolved) and resolved == _active_profile_path_str(), "model": _profile_attr(info, "model"), "provider": _profile_attr(info, "provider"), "has_env": bool(_profile_attr(info, "has_env", False)), @@ -2676,6 +2691,14 @@ def _safe(callable_, default): except Exception: return default + active_path_str = _active_profile_path_str() + + def _is_active(path: Path) -> bool: + try: + return bool(active_path_str) and str(path.resolve()) == active_path_str + except Exception: + return False + profiles: List[Dict[str, Any]] = [] default_home = profiles_mod._get_default_hermes_home() if default_home.is_dir(): @@ -2684,6 +2707,7 @@ def _safe(callable_, default): "name": "default", "path": str(default_home), "is_default": True, + "is_active": _is_active(default_home), "model": model, "provider": provider, "has_env": (default_home / ".env").exists(), @@ -2700,6 +2724,7 @@ def _safe(callable_, default): "name": entry.name, "path": str(entry), "is_default": False, + "is_active": _is_active(entry), "model": model, "provider": provider, "has_env": (entry / ".env").exists(), @@ -2917,6 +2942,158 @@ async def toggle_skill(body: SkillToggle): return {"ok": True, "name": body.name, "enabled": body.enabled} +# --------------------------------------------------------------------------- +# Per-profile skills endpoints +# +# The legacy /api/skills routes above operate on the active profile +# (whichever HERMES_HOME the dashboard process was launched under). The +# routes below let the UI list and toggle skills for any installed profile +# without spawning a second dashboard daemon per profile. +# +# Reads/writes go directly against the profile's config.yaml (skills.disabled +# key) rather than through load_config / save_config — those helpers are +# bound to the process-level HERMES_HOME via get_config_path(). +# --------------------------------------------------------------------------- + + +def _profile_config_path(profile_dir: Path) -> Path: + return profile_dir / "config.yaml" + + +def _load_profile_raw_config(profile_dir: Path) -> Dict[str, Any]: + """Read a profile's config.yaml as raw YAML. Returns {} if missing/empty.""" + config_path = _profile_config_path(profile_dir) + if not config_path.exists(): + return {} + try: + import yaml as _yaml + with open(config_path, encoding="utf-8") as f: + data = _yaml.safe_load(f) + return data if isinstance(data, dict) else {} + except OSError as e: + raise HTTPException(status_code=500, detail=f"Could not read config.yaml: {e}") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Could not parse config.yaml: {e}") + + +def _save_profile_raw_config(profile_dir: Path, config: Dict[str, Any]) -> None: + from utils import atomic_yaml_write + try: + atomic_yaml_write(_profile_config_path(profile_dir), config) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Could not write config.yaml: {e}") + + +def _find_skills_in_profile(profile_dir: Path) -> List[Dict[str, Any]]: + """Scan profile_dir/skills/ for SKILL.md files and return the same shape + as ``tools.skills_tool._find_all_skills`` (without external_dirs). + + External-dir scanning is intentionally omitted for v1 — the dropdown is + aimed at toggling profile-installed skills. Skills loaded via + ``skills.external_dirs`` are still respected at gateway runtime. + + Category is derived from the directory layout under the profile's own + skills/ root (matching the convention used by + ``tools.skills_tool._get_category_from_path``, which is hardcoded to the + process-level SKILLS_DIR and therefore can't be reused here). + """ + from tools.skills_tool import ( + MAX_DESCRIPTION_LENGTH, + MAX_NAME_LENGTH, + _EXCLUDED_SKILL_DIRS, + _parse_frontmatter, + skill_matches_platform, + ) + from agent.skill_utils import iter_skill_index_files + + skills_dir = profile_dir / "skills" + if not skills_dir.is_dir(): + return [] + + def _category(skill_md_path: Path) -> Optional[str]: + try: + rel = skill_md_path.relative_to(skills_dir) + except ValueError: + return None + # rel like "mlops/axolotl/SKILL.md" → "mlops"; "airtable/SKILL.md" → None + return rel.parts[0] if len(rel.parts) >= 3 else None + + out: List[Dict[str, Any]] = [] + seen: set = set() + for skill_md in iter_skill_index_files(skills_dir, "SKILL.md"): + if any(part in _EXCLUDED_SKILL_DIRS for part in skill_md.parts): + continue + try: + content = skill_md.read_text(encoding="utf-8")[:4000] + frontmatter, body = _parse_frontmatter(content) + if not skill_matches_platform(frontmatter): + continue + name = frontmatter.get("name", skill_md.parent.name)[:MAX_NAME_LENGTH] + if name in seen: + continue + description = frontmatter.get("description", "") + if not description: + for line in body.strip().split("\n"): + line = line.strip() + if line and not line.startswith("#"): + description = line + break + if len(description) > MAX_DESCRIPTION_LENGTH: + description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..." + seen.add(name) + out.append({ + "name": name, + "description": description, + "category": _category(skill_md), + "path": str(skill_md.parent), + }) + except (OSError, UnicodeDecodeError): + continue + out.sort(key=lambda s: s["name"].lower()) + return out + + +def _profile_disabled_skills(config: Dict[str, Any]) -> set: + skills_cfg = config.get("skills") if isinstance(config, dict) else None + if not isinstance(skills_cfg, dict): + return set() + disabled = skills_cfg.get("disabled", []) + if not isinstance(disabled, list): + return set() + return {str(x) for x in disabled} + + +@app.get("/api/profiles/{name}/skills") +async def get_profile_skills(name: str): + profile_dir = _resolve_profile_dir(name) + config = _load_profile_raw_config(profile_dir) + disabled = _profile_disabled_skills(config) + skills = _find_skills_in_profile(profile_dir) + for s in skills: + s["enabled"] = s["name"] not in disabled + return skills + + +@app.put("/api/profiles/{name}/skills/toggle") +async def toggle_profile_skill(name: str, body: SkillToggle): + profile_dir = _resolve_profile_dir(name) + config = _load_profile_raw_config(profile_dir) + skills_cfg = config.setdefault("skills", {}) if isinstance(config, dict) else None + if not isinstance(skills_cfg, dict): + # The skills key existed but wasn't a dict — overwrite with a fresh mapping. + config["skills"] = {} + skills_cfg = config["skills"] + raw_disabled = skills_cfg.get("disabled", []) + disabled = {str(x) for x in raw_disabled} if isinstance(raw_disabled, list) else set() + if body.enabled: + disabled.discard(body.name) + else: + disabled.add(body.name) + skills_cfg["disabled"] = sorted(disabled) + _save_profile_raw_config(profile_dir, config) + return {"ok": True, "name": body.name, "enabled": body.enabled, "profile": name} + + @app.get("/api/tools/toolsets") async def get_toolsets(): from hermes_cli.tools_config import ( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 4d177f92b385a..cf0ad71795f6a 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -853,6 +853,110 @@ def _fake_find_all_skills(*, skip_disabled=False): }, ] + def test_profiles_list_marks_default_profile_active(self): + """The default profile (whose HERMES_HOME the dashboard process is bound + to) should report is_active=True; sub-profiles created during the test + should not.""" + import hermes_cli.profiles as profiles_mod + # Sub-profile creation paths sometimes call into the gateway service + # cleanup helper — stub it out so the test stays hermetic. + # (mirrors test_profile_soul_round_trip) + try: + import pytest # noqa: F401 + except ImportError: + pass + profiles_mod_orig = profiles_mod._cleanup_gateway_service + profiles_mod._cleanup_gateway_service = lambda *a, **kw: None + try: + self.client.post("/api/profiles", json={"name": "active-test-prof"}) + data = self.client.get("/api/profiles").json() + finally: + self.client.delete("/api/profiles/active-test-prof") + profiles_mod._cleanup_gateway_service = profiles_mod_orig + by_name = {p["name"]: p for p in data["profiles"]} + assert by_name["default"]["is_active"] is True + if "active-test-prof" in by_name: + assert by_name["active-test-prof"]["is_active"] is False + + def test_profile_skills_list_picks_up_profile_dir(self, monkeypatch): + """GET /api/profiles/{name}/skills should scan the profile's own + skills/ directory, not the process-level HERMES_HOME.""" + from pathlib import Path + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "skill-scope-prof"}) + try: + profile_dir = Path(profiles_mod.get_profile_dir("skill-scope-prof")) + skill_dir = profile_dir / "skills" / "productivity" / "ben-only-skill" + skill_dir.mkdir(parents=True, exist_ok=True) + (skill_dir / "SKILL.md").write_text( + "---\n" + "name: ben-only-skill\n" + "description: A skill that exists only in skill-scope-prof.\n" + "---\n" + "# Body\n", + encoding="utf-8", + ) + resp = self.client.get("/api/profiles/skill-scope-prof/skills") + assert resp.status_code == 200 + skills = resp.json() + found = [s for s in skills if s["name"] == "ben-only-skill"] + assert len(found) == 1, f"expected 1 ben-only-skill, got {skills}" + assert found[0]["enabled"] is True + assert found[0]["category"] == "productivity" + assert found[0]["description"].startswith("A skill that exists") + finally: + self.client.delete("/api/profiles/skill-scope-prof") + + def test_profile_skill_toggle_persists_to_profile_config(self, monkeypatch): + """PUT /api/profiles/{name}/skills/toggle should write to the profile's + own config.yaml, leaving the dashboard's active profile untouched.""" + from pathlib import Path + import yaml + import hermes_cli.profiles as profiles_mod + monkeypatch.setattr(profiles_mod, "_cleanup_gateway_service", lambda *a, **kw: None) + + self.client.post("/api/profiles", json={"name": "toggle-prof"}) + try: + put = self.client.put( + "/api/profiles/toggle-prof/skills/toggle", + json={"name": "fake-skill", "enabled": False}, + ) + assert put.status_code == 200 + assert put.json()["profile"] == "toggle-prof" + assert put.json()["enabled"] is False + + profile_cfg = Path(profiles_mod.get_profile_dir("toggle-prof")) / "config.yaml" + assert profile_cfg.exists() + parsed = yaml.safe_load(profile_cfg.read_text(encoding="utf-8")) or {} + assert parsed.get("skills", {}).get("disabled") == ["fake-skill"] + + # Re-enable removes the entry rather than leaving a stale flag. + put2 = self.client.put( + "/api/profiles/toggle-prof/skills/toggle", + json={"name": "fake-skill", "enabled": True}, + ) + assert put2.status_code == 200 + parsed2 = yaml.safe_load(profile_cfg.read_text(encoding="utf-8")) or {} + assert parsed2.get("skills", {}).get("disabled") == [] + finally: + self.client.delete("/api/profiles/toggle-prof") + + def test_profile_skills_unknown_profile_404(self): + resp = self.client.get("/api/profiles/nonexistent/skills") + assert resp.status_code == 404 + resp2 = self.client.put( + "/api/profiles/nonexistent/skills/toggle", + json={"name": "x", "enabled": False}, + ) + assert resp2.status_code == 404 + + def test_profile_skills_invalid_name_400(self): + resp = self.client.get("/api/profiles/Bad@Name/skills") + assert resp.status_code == 400 + assert "Invalid profile name" in resp.json()["detail"] + def test_toolsets_list(self): resp = self.client.get("/api/tools/toolsets") assert resp.status_code == 200 diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 2b571b627716e..a91c7326a397d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -203,6 +203,20 @@ export const api = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name, enabled }), }), + // Per-profile skill management. The dashboard daemon edits the active + // profile by default; these routes let it edit other installed profiles + // without spawning a second daemon per profile. + getProfileSkills: (profile: string) => + fetchJSON(`/api/profiles/${encodeURIComponent(profile)}/skills`), + toggleProfileSkill: (profile: string, name: string, enabled: boolean) => + fetchJSON<{ ok: boolean }>( + `/api/profiles/${encodeURIComponent(profile)}/skills/toggle`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, enabled }), + }, + ), getToolsets: () => fetchJSON("/api/tools/toolsets"), // Session search (FTS5) @@ -505,6 +519,13 @@ export interface ProfileInfo { name: string; path: string; is_default: boolean; + /** + * True when this profile is the one the running dashboard process is + * bound to (matches HERMES_HOME). Optional for backward-compat with + * older gateways that don't emit the field — callers should treat + * undefined as false. + */ + is_active?: boolean; model: string | null; provider: string | null; has_env: boolean; diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index f31b37d915f36..ea1bbf41a8254 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -16,13 +16,14 @@ import { Filter, } from "lucide-react"; import { api } from "@/lib/api"; -import type { SkillInfo, ToolsetInfo } from "@/lib/api"; +import type { ProfileInfo, SkillInfo, ToolsetInfo } from "@/lib/api"; import { useToast } from "@/hooks/useToast"; import { Toast } from "@/components/Toast"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Switch } from "@nous-research/ui/ui/components/switch"; import { cn } from "@/lib/utils"; @@ -96,7 +97,10 @@ function toolsetIcon( export default function SkillsPage() { const [skills, setSkills] = useState([]); const [toolsets, setToolsets] = useState([]); + const [profiles, setProfiles] = useState([]); + const [selectedProfile, setSelectedProfile] = useState(""); const [loading, setLoading] = useState(true); + const [skillsLoading, setSkillsLoading] = useState(false); const [search, setSearch] = useState(""); const [view, setView] = useState<"skills" | "toolsets">("skills"); const [activeCategory, setActiveCategory] = useState(null); @@ -105,21 +109,76 @@ export default function SkillsPage() { const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); + // Treat the profile flagged is_active by the gateway as "the dashboard's + // own profile". Older gateways don't emit is_active; fall back to the + // is_default profile so the dropdown still reflects whichever profile the + // legacy /api/skills routes actually edit. + const activeProfileName = useMemo(() => { + const active = profiles.find((p) => p.is_active); + if (active) return active.name; + const dflt = profiles.find((p) => p.is_default); + return dflt?.name ?? profiles[0]?.name ?? ""; + }, [profiles]); + + const isOnActiveProfile = + !selectedProfile || selectedProfile === activeProfileName; + + /* ---- Initial load: profiles, toolsets, and the active profile's skills ---- */ useEffect(() => { - Promise.all([api.getSkills(), api.getToolsets()]) - .then(([s, tsets]) => { - setSkills(s); + Promise.all([api.getProfiles(), api.getToolsets(), api.getSkills()]) + .then(([{ profiles: profileList }, tsets, s]) => { + setProfiles(profileList); setToolsets(tsets); + setSkills(s); + const active = + profileList.find((p) => p.is_active) ?? + profileList.find((p) => p.is_default) ?? + profileList[0]; + setSelectedProfile(active?.name ?? ""); }) .catch(() => showToast(t.common.loading, "error")) .finally(() => setLoading(false)); }, []); + /* ---- Refetch skills when the selected profile changes ---- + * + * Skipped on initial mount: the load effect above already fetched the + * active profile's skills via the legacy /api/skills route, which is + * cheaper than the profile-scoped scan and stays in sync with the + * gateway-resident skill index. + */ + useEffect(() => { + if (loading || !selectedProfile) return; + if (selectedProfile === activeProfileName) { + setSkillsLoading(true); + api + .getSkills() + .then(setSkills) + .catch(() => showToast(t.common.loading, "error")) + .finally(() => setSkillsLoading(false)); + return; + } + setSkillsLoading(true); + api + .getProfileSkills(selectedProfile) + .then(setSkills) + .catch(() => showToast(t.common.loading, "error")) + .finally(() => setSkillsLoading(false)); + }, [selectedProfile, activeProfileName, loading]); + /* ---- Toggle skill ---- */ const handleToggleSkill = async (skill: SkillInfo) => { setTogglingSkills((prev) => new Set(prev).add(skill.name)); try { - await api.toggleSkill(skill.name, !skill.enabled); + if (isOnActiveProfile) { + await api.toggleSkill(skill.name, !skill.enabled); + } else { + await api.toggleProfileSkill( + selectedProfile, + skill.name, + !skill.enabled, + ); + } setSkills((prev) => prev.map((s) => s.name === skill.name ? { ...s, enabled: !s.enabled } : s, @@ -255,7 +314,33 @@ export default function SkillsPage() {