feat(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. 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 <profile> 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=<name>),
  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).
This commit is contained in:
Teknium
2026-06-10 20:34:53 -07:00
parent acd7932c0f
commit 914befa9aa
8 changed files with 662 additions and 152 deletions
+222 -105
View File
@@ -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", <name>]`` 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}