From 914befa9aaae46f2709373bc3d7352e813a18c54 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 10 Jun 2026 15:36:51 -0700 Subject: [PATCH 01/10] feat(dashboard): profile-scoped skills & toolsets management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'Set as active' on the Profiles page only flips the sticky active_profile file (future CLI/gateway runs) — it never retargets the running dashboard process. The skills/toolsets endpoints called bare load_config()/ save_config(), so after 'activating' a profile in the web UI, deactivating a skill silently wrote into the dashboard's own profile and the activated profile was untouched. Backend: - _profile_scope() context manager on the skills/toolsets endpoints: context-local HERMES_HOME override for call-time config resolution + cron-style locked swap of tools.skills_tool's import-time SKILLS_DIR - profile param on /api/skills, /api/skills/toggle, /api/tools/toolsets* (list/toggle/config/provider/env), hub sources/search installed-state - hub install/uninstall/update spawn 'hermes -p skills ...' so the child rebinds skills_hub.SKILLS_DIR at import (the override cannot reach import-time globals); profile validated -> 404/400 before spawn Frontend: - Skills page: profile selector (deep-linkable /skills?profile=), amber banner naming the managed profile, threaded through skill toggles, toolset drawer, and hub browser - Profiles page: 'Manage skills & tools' action per card; 'Set as active' toast now says it applies to new CLI/gateway runs only Omitted profile keeps legacy behavior (dashboard's own profile). --- hermes_cli/web_server.py | 327 ++++++++++++------ .../test_web_server_skills_profiles.py | 210 +++++++++++ web/src/components/ToolsetConfigDrawer.tsx | 15 +- web/src/i18n/en.ts | 4 + web/src/i18n/types.ts | 6 + web/src/lib/api.ts | 66 ++-- web/src/pages/ProfilesPage.tsx | 34 +- web/src/pages/SkillsPage.tsx | 152 +++++++- 8 files changed, 662 insertions(+), 152 deletions(-) create mode 100644 tests/hermes_cli/test_web_server_skills_profiles.py diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e1f1c62051d..f7cc695874a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -9,7 +9,7 @@ Usage: python -m hermes_cli.main web --port 8080 """ -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager import asyncio import base64 @@ -7373,6 +7373,24 @@ async def prune_checkpoints(): class SkillInstallRequest(BaseModel): identifier: str + profile: Optional[str] = None + + +def _profile_cli_args(profile: Optional[str]) -> List[str]: + """Return ``["-p", ]`` for a validated non-default profile. + + Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and + ``_apply_profile_override()`` reads ``-p`` from argv in the child — the + only mechanism that reaches import-time-bound globals like + ``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own + profile (no args, legacy behavior). + """ + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + return [] + from hermes_cli import profiles as profiles_mod + _resolve_profile_dir(requested) + return ["-p", profiles_mod.normalize_profile_name(requested)] @app.post("/api/skills/hub/install") @@ -7381,7 +7399,12 @@ async def install_skill_hub(body: SkillInstallRequest): if not identifier: raise HTTPException(status_code=400, detail="identifier is required") try: - proc = _spawn_hermes_action(["skills", "install", identifier], "skills-install") + proc = _spawn_hermes_action( + _profile_cli_args(body.profile) + ["skills", "install", identifier], + "skills-install", + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills install") raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}") @@ -7390,6 +7413,7 @@ async def install_skill_hub(body: SkillInstallRequest): class SkillUninstallRequest(BaseModel): name: str + profile: Optional[str] = None @app.post("/api/skills/hub/uninstall") @@ -7398,17 +7422,31 @@ async def uninstall_skill_hub(body: SkillUninstallRequest): if not name: raise HTTPException(status_code=400, detail="name is required") try: - proc = _spawn_hermes_action(["skills", "uninstall", name, "--yes"], "skills-uninstall") + proc = _spawn_hermes_action( + _profile_cli_args(body.profile) + ["skills", "uninstall", name, "--yes"], + "skills-uninstall", + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills uninstall") raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}") return {"ok": True, "pid": proc.pid, "name": "skills-uninstall"} +class SkillsUpdateRequest(BaseModel): + profile: Optional[str] = None + + @app.post("/api/skills/hub/update") -async def update_skills_hub(): +async def update_skills_hub(body: Optional[SkillsUpdateRequest] = None): try: - proc = _spawn_hermes_action(["skills", "update"], "skills-update") + profile = body.profile if body else None + proc = _spawn_hermes_action( + _profile_cli_args(profile) + ["skills", "update"], "skills-update" + ) + except HTTPException: + raise except Exception as exc: _log.exception("Failed to spawn skills update") raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}") @@ -7443,17 +7481,25 @@ def _skill_meta_to_payload(m) -> dict: } -def _installed_hub_identifiers() -> dict: +def _installed_hub_identifiers(profile: Optional[str] = None) -> dict: """Map identifier -> installed lock entry for hub-installed skills. - Lets the UI mark search results that are already installed. Best-effort: - returns an empty dict if the lock file can't be read. + Lets the UI mark search results that are already installed. Scoped to + ``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an + explicit path, sidestepping the import-time LOCK_FILE binding). + Best-effort: returns an empty dict if the lock file can't be read. """ try: from tools.skills_hub import HubLockFile + requested = (profile or "").strip() + if requested and requested.lower() != "current": + profile_dir = _resolve_profile_dir(requested) + lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json") + else: + lock = HubLockFile() out = {} - for entry in HubLockFile().list_installed(): + for entry in lock.list_installed(): ident = entry.get("identifier") if ident: out[ident] = { @@ -7467,13 +7513,14 @@ def _installed_hub_identifiers() -> dict: @app.get("/api/skills/hub/sources") -async def list_skills_hub_sources(): +async def list_skills_hub_sources(profile: Optional[str] = None): """List the configured skill-hub sources and installed-skill provenance. Gives the dashboard something to show BEFORE a search runs — which hubs are wired up, their trust tier, and a set of featured skills pulled from the centralized index (zero extra API calls). Without this the Browse-hub tab is a blank page with no indication it's even connected to anything. + ``profile`` scopes the installed-skill provenance to that profile. """ def _run(): @@ -7514,18 +7561,22 @@ async def list_skills_hub_sources(): "sources": out, "index_available": index_available, "featured": featured, - "installed": _installed_hub_identifiers(), + "installed": _installed_hub_identifiers(profile), } try: return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as exc: _log.exception("skills hub sources listing failed") raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}") @app.get("/api/skills/hub/search") -async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): +async def search_skills_hub( + q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None +): """Search the skill hub across all configured sources. Network-bound (parallel source search); runs in a thread so the FastAPI @@ -7560,11 +7611,13 @@ async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): "results": [_skill_meta_to_payload(m) for m in deduped], "source_counts": source_counts, "timed_out": timed_out, - "installed": _installed_hub_identifiers(), + "installed": _installed_hub_identifiers(profile), } try: return await asyncio.to_thread(_run) + except HTTPException: + raise except Exception as exc: _log.exception("skills hub search failed") raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}") @@ -8333,21 +8386,75 @@ async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): # --------------------------------------------------------------------------- # Skills & Tools endpoints +# +# Every read/write below accepts an optional ``profile`` query param so the +# dashboard can manage ANY profile's skills/toolsets, not just the profile +# the dashboard process happens to be running under. Without this, "Set as +# active" on the Profiles page (which only flips the sticky ``active_profile`` +# file for FUTURE CLI/gateway invocations) misled users into thinking skill +# toggles would land in the activated profile — they silently wrote into the +# dashboard's own config instead. See _profile_scope() for the mechanism. # --------------------------------------------------------------------------- +_SKILLS_PROFILE_LOCK = threading.RLock() + + +@contextmanager +def _profile_scope(profile: Optional[str]): + """Scope config + skill-directory resolution to ``profile`` for one request. + + Two seams must be redirected for skills/toolsets endpoints: + + 1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call + time — the context-local override from ``set_hermes_home_override`` + reaches them (same pattern as ``_write_profile_model``). + 2. ``tools.skills_tool`` binds ``SKILLS_DIR`` at import time, so the + override CANNOT reach it. Like ``_call_cron_for_profile`` does for + cron's module globals, temporarily retarget it under a lock and + restore it immediately after. + + ``profile`` of None/""/"current" means "the dashboard's own profile" — + a no-op scope, preserving existing behavior for old clients. + """ + requested = (profile or "").strip() + if not requested or requested.lower() == "current": + yield None + return + + profile_dir = _resolve_profile_dir(requested) + + from hermes_constants import set_hermes_home_override, reset_hermes_home_override + from tools import skills_tool as _skills_tool + + token = set_hermes_home_override(str(profile_dir)) + with _SKILLS_PROFILE_LOCK: + old_home = _skills_tool.HERMES_HOME + old_skills_dir = _skills_tool.SKILLS_DIR + _skills_tool.HERMES_HOME = profile_dir + _skills_tool.SKILLS_DIR = profile_dir / "skills" + try: + yield profile_dir + finally: + _skills_tool.HERMES_HOME = old_home + _skills_tool.SKILLS_DIR = old_skills_dir + reset_hermes_home_override(token) + + class SkillToggle(BaseModel): name: str enabled: bool + profile: Optional[str] = None @app.get("/api/skills") -async def get_skills(): +async def get_skills(profile: Optional[str] = None): from tools.skills_tool import _find_all_skills from hermes_cli.skills_config import get_disabled_skills - config = load_config() - disabled = get_disabled_skills(config) - skills = _find_all_skills(skip_disabled=True) + with _profile_scope(profile): + config = load_config() + disabled = get_disabled_skills(config) + skills = _find_all_skills(skip_disabled=True) for s in skills: s["enabled"] = s["name"] not in disabled return skills @@ -8356,18 +8463,19 @@ async def get_skills(): @app.put("/api/skills/toggle") async def toggle_skill(body: SkillToggle): from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills - config = load_config() - disabled = get_disabled_skills(config) - if body.enabled: - disabled.discard(body.name) - else: - disabled.add(body.name) - save_disabled_skills(config, disabled) + with _profile_scope(body.profile): + config = load_config() + disabled = get_disabled_skills(config) + if body.enabled: + disabled.discard(body.name) + else: + disabled.add(body.name) + save_disabled_skills(config, disabled) return {"ok": True, "name": body.name, "enabled": body.enabled} @app.get("/api/tools/toolsets") -async def get_toolsets(): +async def get_toolsets(profile: Optional[str] = None): from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, @@ -8376,12 +8484,13 @@ async def get_toolsets(): ) from toolsets import resolve_toolset - config = load_config() - enabled_toolsets = _get_platform_tools( - config, - "cli", - include_default_mcp_servers=False, - ) + with _profile_scope(profile): + config = load_config() + enabled_toolsets = _get_platform_tools( + config, + "cli", + include_default_mcp_servers=False, + ) result = [] for name, label, desc in _get_effective_configurable_toolsets(): try: @@ -8403,6 +8512,7 @@ async def get_toolsets(): class ToolsetToggle(BaseModel): enabled: bool + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}") @@ -8411,7 +8521,8 @@ async def toggle_toolset(name: str, body: ToolsetToggle): Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools`` helper the CLI ``hermes tools`` picker uses, so the GUI and CLI stay in - lockstep. Returns 400 for unknown toolset keys. + lockstep. Scoped to ``body.profile`` when provided. Returns 400 for + unknown toolset keys. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, @@ -8423,20 +8534,21 @@ async def toggle_toolset(name: str, body: ToolsetToggle): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - enabled = set( - _get_platform_tools(config, "cli", include_default_mcp_servers=False) - ) - if body.enabled: - enabled.add(name) - else: - enabled.discard(name) - _save_platform_tools(config, "cli", enabled) + with _profile_scope(body.profile): + config = load_config() + enabled = set( + _get_platform_tools(config, "cli", include_default_mcp_servers=False) + ) + if body.enabled: + enabled.add(name) + else: + enabled.discard(name) + _save_platform_tools(config, "cli", enabled) return {"ok": True, "name": name, "enabled": body.enabled} @app.get("/api/tools/toolsets/{name}/config") -async def get_toolset_config(name: str): +async def get_toolset_config(name: str, profile: Optional[str] = None): """Return the provider matrix + key status for a toolset's config panel. Surfaces the same provider rows the CLI ``hermes tools`` picker shows @@ -8457,38 +8569,39 @@ async def get_toolset_config(name: str): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - cat = TOOL_CATEGORIES.get(name) - providers = [] - active_provider = None - if cat: - for prov in _visible_providers(cat, config, force_fresh=True): - env_vars = [ - { - "key": e["key"], - "prompt": e.get("prompt", e["key"]), - "url": e.get("url"), - "default": e.get("default"), - "is_set": bool(get_env_value(e["key"])), - } - for e in prov.get("env_vars", []) - ] - # Surface the same active-provider determination the CLI picker - # uses (``_is_provider_active``) so the GUI highlights the provider - # actually written to config (e.g. web.backend), not just the first - # keyless one in the list. - is_active = _is_provider_active(prov, config, force_fresh=True) - if is_active and active_provider is None: - active_provider = prov["name"] - providers.append({ - "name": prov["name"], - "badge": prov.get("badge", ""), - "tag": prov.get("tag", ""), - "env_vars": env_vars, - "post_setup": prov.get("post_setup"), - "requires_nous_auth": bool(prov.get("requires_nous_auth")), - "is_active": is_active, - }) + with _profile_scope(profile): + config = load_config() + cat = TOOL_CATEGORIES.get(name) + providers = [] + active_provider = None + if cat: + for prov in _visible_providers(cat, config, force_fresh=True): + env_vars = [ + { + "key": e["key"], + "prompt": e.get("prompt", e["key"]), + "url": e.get("url"), + "default": e.get("default"), + "is_set": bool(get_env_value(e["key"])), + } + for e in prov.get("env_vars", []) + ] + # Surface the same active-provider determination the CLI picker + # uses (``_is_provider_active``) so the GUI highlights the provider + # actually written to config (e.g. web.backend), not just the first + # keyless one in the list. + is_active = _is_provider_active(prov, config, force_fresh=True) + if is_active and active_provider is None: + active_provider = prov["name"] + providers.append({ + "name": prov["name"], + "badge": prov.get("badge", ""), + "tag": prov.get("tag", ""), + "env_vars": env_vars, + "post_setup": prov.get("post_setup"), + "requires_nous_auth": bool(prov.get("requires_nous_auth")), + "is_active": is_active, + }) return { "name": name, "has_category": cat is not None, @@ -8499,6 +8612,7 @@ async def get_toolset_config(name: str): class ToolsetProviderSelect(BaseModel): provider: str + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}/provider") @@ -8520,17 +8634,19 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect): if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - try: - apply_provider_selection(name, body.provider, config) - except KeyError as exc: - raise HTTPException(status_code=400, detail=str(exc).strip('"')) - save_config(config) + with _profile_scope(body.profile): + config = load_config() + try: + apply_provider_selection(name, body.provider, config) + except KeyError as exc: + raise HTTPException(status_code=400, detail=str(exc).strip('"')) + save_config(config) return {"ok": True, "name": name, "provider": body.provider} class ToolsetEnvUpdate(BaseModel): env: Dict[str, str] + profile: Optional[str] = None @app.put("/api/tools/toolsets/{name}/env") @@ -8556,34 +8672,35 @@ async def save_toolset_env(name: str, body: ToolsetEnvUpdate): if name not in valid_ts: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") - config = load_config() - cat = TOOL_CATEGORIES.get(name) - allowed: set[str] = set() - if cat: - for prov in _visible_providers(cat, config, force_fresh=True): - for e in prov.get("env_vars", []): - allowed.add(e["key"]) + with _profile_scope(body.profile): + config = load_config() + cat = TOOL_CATEGORIES.get(name) + allowed: set[str] = set() + if cat: + for prov in _visible_providers(cat, config, force_fresh=True): + for e in prov.get("env_vars", []): + allowed.add(e["key"]) - unknown = [k for k in body.env if k not in allowed] - if unknown: - raise HTTPException( - status_code=400, - detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}", - ) + unknown = [k for k in body.env if k not in allowed] + if unknown: + raise HTTPException( + status_code=400, + detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}", + ) - saved: List[str] = [] - skipped: List[str] = [] - for key, value in body.env.items(): - if value and value.strip(): - try: - save_env_value(key, value.strip()) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) - saved.append(key) - else: - skipped.append(key) + saved: List[str] = [] + skipped: List[str] = [] + for key, value in body.env.items(): + if value and value.strip(): + try: + save_env_value(key, value.strip()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + saved.append(key) + else: + skipped.append(key) - status = {k: bool(get_env_value(k)) for k in allowed} + status = {k: bool(get_env_value(k)) for k in allowed} return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status} diff --git a/tests/hermes_cli/test_web_server_skills_profiles.py b/tests/hermes_cli/test_web_server_skills_profiles.py new file mode 100644 index 00000000000..9a131bbb246 --- /dev/null +++ b/tests/hermes_cli/test_web_server_skills_profiles.py @@ -0,0 +1,210 @@ +"""Regression tests for dashboard profile-scoped skills/toolsets management. + +"Set as active" on the Profiles page only flips the sticky ``active_profile`` +file (future CLI/gateway runs) — it never retargets the running dashboard +process. Before the ``profile`` parameter existed, toggling a skill after +"activating" a profile silently wrote into the dashboard's own config. +These tests pin the new behavior: reads and writes land in the REQUESTED +profile's HERMES_HOME, and the dashboard's own profile stays untouched. +""" +import pytest +import yaml + + +def _write_skill(skills_dir, name, description="test skill"): + d = skills_dir / name + d.mkdir(parents=True, exist_ok=True) + (d / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: {description}\n---\n\n# {name}\n", + encoding="utf-8", + ) + + +@pytest.fixture +def isolated_profiles(tmp_path, monkeypatch, _isolate_hermes_home): + """Isolated default home + one named profile, each with its own skills.""" + from hermes_constants import get_hermes_home + from hermes_cli import profiles + + default_home = get_hermes_home() + profiles_root = default_home / "profiles" + worker_home = profiles_root / "worker_alpha" + for home in (default_home, worker_home): + (home / "skills").mkdir(parents=True, exist_ok=True) + (home / "config.yaml").write_text("{}\n", encoding="utf-8") + + _write_skill(default_home / "skills", "dashboard-skill") + _write_skill(worker_home / "skills", "worker-skill") + + monkeypatch.setattr(profiles, "_get_default_hermes_home", lambda: default_home) + monkeypatch.setattr(profiles, "_get_profiles_root", lambda: profiles_root) + return {"default": default_home, "worker_alpha": worker_home} + + +@pytest.fixture +def client(monkeypatch, isolated_profiles): + try: + from starlette.testclient import TestClient + except ImportError: + pytest.skip("fastapi/starlette not installed") + + import hermes_state + from hermes_constants import get_hermes_home + from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN + + monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db") + c = TestClient(app) + c.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN + return c + + +def _load_cfg(home): + return yaml.safe_load((home / "config.yaml").read_text()) or {} + + +class TestProfileScopedSkills: + def test_skills_list_scopes_to_requested_profile(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "worker_alpha"}) + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert "worker-skill" in names + assert "dashboard-skill" not in names + + def test_skills_list_without_profile_uses_dashboard_home( + self, client, isolated_profiles + ): + resp = client.get("/api/skills") + assert resp.status_code == 200 + names = {s["name"] for s in resp.json()} + assert "dashboard-skill" in names + assert "worker-skill" not in names + + def test_toggle_writes_into_target_profile_only(self, client, isolated_profiles): + resp = client.put( + "/api/skills/toggle", + json={"name": "worker-skill", "enabled": False, "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + assert resp.json() == {"ok": True, "name": "worker-skill", "enabled": False} + + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "worker-skill" in worker_cfg.get("skills", {}).get("disabled", []) + # The dashboard's own config must stay untouched — this was the bug. + default_cfg = _load_cfg(isolated_profiles["default"]) + assert "worker-skill" not in default_cfg.get("skills", {}).get("disabled", []) + + def test_toggle_reenable_round_trip(self, client, isolated_profiles): + for enabled in (False, True): + client.put( + "/api/skills/toggle", + json={ + "name": "worker-skill", + "enabled": enabled, + "profile": "worker_alpha", + }, + ) + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "worker-skill" not in worker_cfg.get("skills", {}).get("disabled", []) + + def test_unknown_profile_returns_404(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "no_such_profile"}) + assert resp.status_code == 404 + + def test_invalid_profile_name_returns_400(self, client, isolated_profiles): + resp = client.get("/api/skills", params={"profile": "Bad Name!"}) + assert resp.status_code == 400 + + def test_scope_restores_module_globals(self, client, isolated_profiles): + """The SKILLS_DIR swap is per-request; the module global must be + restored even after a scoped call (cron-style locked swap).""" + import tools.skills_tool as skills_tool + + before = skills_tool.SKILLS_DIR + client.get("/api/skills", params={"profile": "worker_alpha"}) + assert skills_tool.SKILLS_DIR == before + + +class TestProfileScopedToolsets: + def test_toolset_toggle_scopes_to_profile(self, client, isolated_profiles): + resp = client.put( + "/api/tools/toolsets/x_search", + json={"enabled": True, "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + + worker_cfg = _load_cfg(isolated_profiles["worker_alpha"]) + assert "x_search" in worker_cfg.get("platform_toolsets", {}).get("cli", []) + default_cfg = _load_cfg(isolated_profiles["default"]) + assert "x_search" not in default_cfg.get("platform_toolsets", {}).get("cli", []) + + listing = client.get( + "/api/tools/toolsets", params={"profile": "worker_alpha"} + ).json() + assert {t["name"]: t for t in listing}["x_search"]["enabled"] is True + # Unscoped listing reflects the dashboard's own (untouched) config. + listing = client.get("/api/tools/toolsets").json() + assert {t["name"]: t for t in listing}["x_search"]["enabled"] is False + + def test_toolset_toggle_unknown_profile_404(self, client, isolated_profiles): + resp = client.put( + "/api/tools/toolsets/x_search", + json={"enabled": True, "profile": "ghost"}, + ) + assert resp.status_code == 404 + + +class TestProfileScopedHubActions: + def test_hub_install_spawns_with_profile_flag( + self, client, isolated_profiles, monkeypatch + ): + """Hub installs must go through a fresh ``hermes -p `` + subprocess — the in-process scope can't reach skills_hub's + import-time SKILLS_DIR binding.""" + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 4242 + + def _fake_spawn(subcommand, name): + calls.append((list(subcommand), name)) + return _FakeProc() + + monkeypatch.setattr(web_server, "_spawn_hermes_action", _fake_spawn) + resp = client.post( + "/api/skills/hub/install", + json={"identifier": "official/demo", "profile": "worker_alpha"}, + ) + assert resp.status_code == 200 + assert calls == [ + (["-p", "worker_alpha", "skills", "install", "official/demo"], "skills-install") + ] + + def test_hub_install_without_profile_keeps_legacy_argv( + self, client, isolated_profiles, monkeypatch + ): + import hermes_cli.web_server as web_server + + calls = [] + + class _FakeProc: + pid = 4242 + + monkeypatch.setattr( + web_server, + "_spawn_hermes_action", + lambda subcommand, name: calls.append(list(subcommand)) or _FakeProc(), + ) + resp = client.post( + "/api/skills/hub/install", json={"identifier": "official/demo"} + ) + assert resp.status_code == 200 + assert calls == [["skills", "install", "official/demo"]] + + def test_hub_install_unknown_profile_404(self, client, isolated_profiles): + resp = client.post( + "/api/skills/hub/install", + json={"identifier": "official/demo", "profile": "ghost"}, + ) + assert resp.status_code == 404 diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index 42e58d589f5..5bbcba61866 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -20,6 +20,9 @@ import { cn, themedBody } from "@/lib/utils"; interface Props { /** The toolset whose backends are being configured. */ toolset: ToolsetInfo; + /** Optional profile to scope config reads/writes to (Skills page profile + * selector). Omitted = the dashboard process's own profile. */ + profile?: string; onClose: () => void; /** Called after a toggle/provider/key change so the parent grid refreshes. */ onChanged: () => void; @@ -31,7 +34,7 @@ interface Props { * the toolset on/off, pick a provider, enter API keys, and run a provider's * post-setup install hook (npm/pip/binary) with a live log tail. */ -export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { +export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Props) { const { toast, showToast } = useToast(); const [config, setConfig] = useState(null); const [loading, setLoading] = useState(true); @@ -60,7 +63,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { // react-hooks/set-state-in-effect — setState only fires inside the // async .then/.catch/.finally callbacks. return api - .getToolsetConfig(toolset.name) + .getToolsetConfig(toolset.name, profile) .then((cfg) => { setConfig(cfg); setActiveProvider(cfg.active_provider); @@ -72,7 +75,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { }) .catch(() => showToast("Failed to load toolset config", "error")) .finally(() => setLoading(false)); - }, [toolset.name, showToast]); + }, [toolset.name, profile, showToast]); useEffect(() => { void loadConfig(); @@ -121,7 +124,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { const handleToggle = async (next: boolean) => { setToggling(true); try { - await api.toggleToolset(toolset.name, next); + await api.toggleToolset(toolset.name, next, profile); setEnabled(next); showToast( `${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`, @@ -138,7 +141,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { const handleSelectProvider = async (provider: ToolsetProvider) => { setSelecting(provider.name); try { - await api.selectToolsetProvider(toolset.name, provider.name); + await api.selectToolsetProvider(toolset.name, provider.name, profile); setActiveProvider(provider.name); showToast(`Provider set to ${provider.name}`, "success"); onChanged(); @@ -164,7 +167,7 @@ export function ToolsetConfigDrawer({ toolset, onClose, onChanged }: Props) { } setSavingProvider(provider.name); try { - const res = await api.saveToolsetEnv(toolset.name, env); + const res = await api.saveToolsetEnv(toolset.name, env, profile); setIsSet((prev) => ({ ...prev, ...res.is_set })); // Clear saved drafts so the inputs reset to the "saved" placeholder. setDrafts((prev) => { diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4f593487fd3..39cf80d6995 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -408,6 +408,10 @@ export const en: Translations = { setupNeeded: "Setup needed", disabledForCli: "Disabled for CLI", more: "+{count} more", + profileSelector: "Profile", + currentProfile: "current ({name})", + managingProfile: + "Managing profile \u201c{name}\u201d — toggles apply to that profile, not this dashboard\u2019s.", }, config: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 14bc41f2d08..cac5688bdc6 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -404,6 +404,8 @@ export interface Translations { modelSaved?: string; modelSelect?: string; actions?: string; + manageSkills?: string; + activeSetHint?: string; }; // ── Skills page ── @@ -425,6 +427,10 @@ export interface Translations { setupNeeded: string; disabledForCli: string; more: string; + /** Optional — fall back to English literals until translated. */ + profileSelector?: string; + currentProfile?: string; + managingProfile?: string; }; // ── Config page ── diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 37a8f15eba2..7cde6eb78f9 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -249,6 +249,14 @@ export async function buildWsUrl( return `${proto}//${window.location.host}${BASE}${path}?${qs}`; } +/** Build a ``?profile=`` query suffix, or "" when unset. + * + * Used by the skills/toolsets endpoints so the dashboard can manage a + * profile other than the one the server process runs under. */ +function profileQuery(profile?: string): string { + return profile ? `?profile=${encodeURIComponent(profile)}` : ""; +} + export const api = { getStatus: () => fetchJSON("/api/status"), /** @@ -542,43 +550,49 @@ export const api = { ), // Skills & Toolsets - getSkills: () => fetchJSON("/api/skills"), - toggleSkill: (name: string, enabled: boolean) => + // + // All calls accept an optional ``profile`` so the Skills page can manage + // any profile's skills/toolsets — not just the one the dashboard process + // runs under. Omitted/empty profile = the dashboard's own profile. + getSkills: (profile?: string) => + fetchJSON(`/api/skills${profileQuery(profile)}`), + toggleSkill: (name: string, enabled: boolean, profile?: string) => fetchJSON<{ ok: boolean }>("/api/skills/toggle", { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, enabled }), + body: JSON.stringify({ name, enabled, profile: profile || undefined }), }), - getToolsets: () => fetchJSON("/api/tools/toolsets"), - toggleToolset: (name: string, enabled: boolean) => + getToolsets: (profile?: string) => + fetchJSON(`/api/tools/toolsets${profileQuery(profile)}`), + toggleToolset: (name: string, enabled: boolean, profile?: string) => fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( `/api/tools/toolsets/${encodeURIComponent(name)}`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enabled }), + body: JSON.stringify({ enabled, profile: profile || undefined }), }, ), - getToolsetConfig: (name: string) => + getToolsetConfig: (name: string, profile?: string) => fetchJSON( - `/api/tools/toolsets/${encodeURIComponent(name)}/config`, + `/api/tools/toolsets/${encodeURIComponent(name)}/config${profileQuery(profile)}`, ), - selectToolsetProvider: (name: string, provider: string) => + selectToolsetProvider: (name: string, provider: string, profile?: string) => fetchJSON<{ ok: boolean; name: string; provider: string }>( `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider }), + body: JSON.stringify({ provider, profile: profile || undefined }), }, ), - saveToolsetEnv: (name: string, env: Record) => + saveToolsetEnv: (name: string, env: Record, profile?: string) => fetchJSON( `/api/tools/toolsets/${encodeURIComponent(name)}/env`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ env }), + body: JSON.stringify({ env, profile: profile || undefined }), }, ), runToolsetPostSetup: (name: string, key: string) => @@ -986,26 +1000,34 @@ export const api = { fetchJSON("/api/ops/checkpoints/prune", { method: "POST" }), // ── Admin: Skills hub ─────────────────────────────────────────────── - installSkillFromHub: (identifier: string) => + // ``profile`` scopes install/uninstall/update and the installed-state + // annotations to that profile (omitted = the dashboard's own profile). + installSkillFromHub: (identifier: string, profile?: string) => fetchJSON("/api/skills/hub/install", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ identifier }), + body: JSON.stringify({ identifier, profile: profile || undefined }), }), - uninstallSkillFromHub: (name: string) => + uninstallSkillFromHub: (name: string, profile?: string) => fetchJSON("/api/skills/hub/uninstall", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name }), + body: JSON.stringify({ name, profile: profile || undefined }), }), - updateSkillsFromHub: () => - fetchJSON("/api/skills/hub/update", { method: "POST" }), - searchSkillsHub: (q: string, source = "all", limit = 20) => + updateSkillsFromHub: (profile?: string) => + fetchJSON("/api/skills/hub/update", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: profile || undefined }), + }), + searchSkillsHub: (q: string, source = "all", limit = 20, profile?: string) => fetchJSON( - `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`, + `/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}${profile ? `&profile=${encodeURIComponent(profile)}` : ""}`, + ), + getSkillHubSources: (profile?: string) => + fetchJSON( + `/api/skills/hub/sources${profileQuery(profile)}`, ), - getSkillHubSources: () => - fetchJSON("/api/skills/hub/sources"), previewSkillFromHub: (identifier: string) => fetchJSON( `/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`, diff --git a/web/src/pages/ProfilesPage.tsx b/web/src/pages/ProfilesPage.tsx index bda2515528f..a1e69bfbcc8 100644 --- a/web/src/pages/ProfilesPage.tsx +++ b/web/src/pages/ProfilesPage.tsx @@ -22,6 +22,7 @@ import { X, } from "lucide-react"; import spinners from "unicode-animations"; +import { useNavigate } from "react-router-dom"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { ActiveProfileInfo, ProfileInfo } from "@/lib/api"; @@ -96,6 +97,7 @@ function ProfileActionsMenu({ onEditDescription, onEditModel, onEditSoul, + onManageSkills, onRename, onSetActive, }: ProfileActionsMenuProps) { @@ -201,6 +203,16 @@ function ProfileActionsMenu({ {labels.editSoul} + +