mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-14 09:11:54 +00:00
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:
parent
acd7932c0f
commit
914befa9aa
8 changed files with 662 additions and 152 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal file
210
tests/hermes_cli/test_web_server_skills_profiles.py
Normal file
|
|
@ -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 <profile>``
|
||||
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
|
||||
|
|
@ -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<ToolsetConfig | null>(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) => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -249,6 +249,14 @@ export async function buildWsUrl(
|
|||
return `${proto}//${window.location.host}${BASE}${path}?${qs}`;
|
||||
}
|
||||
|
||||
/** Build a ``?profile=<name>`` 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<StatusResponse>("/api/status"),
|
||||
/**
|
||||
|
|
@ -542,43 +550,49 @@ export const api = {
|
|||
),
|
||||
|
||||
// Skills & Toolsets
|
||||
getSkills: () => fetchJSON<SkillInfo[]>("/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<SkillInfo[]>(`/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<ToolsetInfo[]>("/api/tools/toolsets"),
|
||||
toggleToolset: (name: string, enabled: boolean) =>
|
||||
getToolsets: (profile?: string) =>
|
||||
fetchJSON<ToolsetInfo[]>(`/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<ToolsetConfig>(
|
||||
`/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<string, string>) =>
|
||||
saveToolsetEnv: (name: string, env: Record<string, string>, profile?: string) =>
|
||||
fetchJSON<ToolsetEnvResult>(
|
||||
`/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<ActionResponse>("/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<ActionResponse>("/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<ActionResponse>("/api/skills/hub/uninstall", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name }),
|
||||
body: JSON.stringify({ name, profile: profile || undefined }),
|
||||
}),
|
||||
updateSkillsFromHub: () =>
|
||||
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
|
||||
searchSkillsHub: (q: string, source = "all", limit = 20) =>
|
||||
updateSkillsFromHub: (profile?: string) =>
|
||||
fetchJSON<ActionResponse>("/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<SkillHubSearchResponse>(
|
||||
`/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<SkillHubSourcesResponse>(
|
||||
`/api/skills/hub/sources${profileQuery(profile)}`,
|
||||
),
|
||||
getSkillHubSources: () =>
|
||||
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
|
||||
previewSkillFromHub: (identifier: string) =>
|
||||
fetchJSON<SkillHubPreview>(
|
||||
`/api/skills/hub/preview?identifier=${encodeURIComponent(identifier)}`,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={itemClass}
|
||||
onClick={run(onManageSkills)}
|
||||
>
|
||||
<Package className="h-4 w-4" />
|
||||
{labels.manageSkills}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
|
|
@ -241,6 +253,7 @@ function ProfileActionsMenu({
|
|||
}
|
||||
|
||||
export default function ProfilesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [activeInfo, setActiveInfo] = useState<ActiveProfileInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
|
@ -291,6 +304,10 @@ export default function ProfilesPage() {
|
|||
modelSaved: p.modelSaved ?? "Model updated",
|
||||
modelSelect: p.modelSelect ?? "Select a model",
|
||||
actions: p.actions ?? "Actions",
|
||||
manageSkills: p.manageSkills ?? "Manage skills & tools",
|
||||
activeSetHint:
|
||||
p.activeSetHint ??
|
||||
"Applies to new CLI/gateway runs. This dashboard still manages its own profile — use “Manage skills & tools” to edit {name}.",
|
||||
};
|
||||
}, [t.profiles]);
|
||||
|
||||
|
|
@ -480,7 +497,14 @@ export default function ProfilesPage() {
|
|||
// The backend normalizes/validates the name; trust the canonical
|
||||
// value it returns rather than the raw input.
|
||||
const { active } = await api.setActiveProfile(name);
|
||||
showToast(`${L.activeSet}: ${active}`, "success");
|
||||
// "Set as active" only flips the sticky default for FUTURE CLI/gateway
|
||||
// invocations — it does NOT retarget this running dashboard. Say so,
|
||||
// or users assume skill/tool toggles now apply to the activated
|
||||
// profile (they don't — that's what "Manage skills & tools" is for).
|
||||
showToast(
|
||||
`${L.activeSet}: ${active} — ${L.activeSetHint.replace("{name}", active)}`,
|
||||
"success",
|
||||
);
|
||||
setActiveInfo((prev) =>
|
||||
prev ? { ...prev, active } : { active, current: active },
|
||||
);
|
||||
|
|
@ -1110,6 +1134,7 @@ export default function ProfilesPage() {
|
|||
editModel: L.editModel,
|
||||
editDescription: L.editDescription,
|
||||
editSoul: t.profiles.editSoul,
|
||||
manageSkills: L.manageSkills,
|
||||
openInTerminal: t.profiles.openInTerminal,
|
||||
rename: t.profiles.rename,
|
||||
delete: t.common.delete,
|
||||
|
|
@ -1121,6 +1146,11 @@ export default function ProfilesPage() {
|
|||
onEditDescription={() => openDescEditor(p)}
|
||||
onEditModel={() => openModelEditor(p)}
|
||||
onEditSoul={() => openSoulEditor(p.name)}
|
||||
onManageSkills={() =>
|
||||
navigate(
|
||||
`/skills?profile=${encodeURIComponent(p.name)}`,
|
||||
)
|
||||
}
|
||||
onRename={() => {
|
||||
setRenamingFrom(p.name);
|
||||
setRenameTo(p.name);
|
||||
|
|
@ -1375,6 +1405,7 @@ interface ProfileActionsMenuProps {
|
|||
editDescription: string;
|
||||
editModel: string;
|
||||
editSoul: string;
|
||||
manageSkills: string;
|
||||
openInTerminal: string;
|
||||
rename: string;
|
||||
setActive: string;
|
||||
|
|
@ -1385,6 +1416,7 @@ interface ProfileActionsMenuProps {
|
|||
onEditDescription: () => void;
|
||||
onEditModel: () => void;
|
||||
onEditSoul: () => void;
|
||||
onManageSkills: () => void;
|
||||
onRename: () => void;
|
||||
onSetActive: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
AlertTriangle,
|
||||
Sparkles,
|
||||
Loader2,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type {
|
||||
|
|
@ -35,7 +36,9 @@ import type {
|
|||
SkillHubInstalledEntry,
|
||||
SkillHubPreview,
|
||||
SkillHubScan,
|
||||
ProfileInfo,
|
||||
} from "@/lib/api";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer";
|
||||
import { useToast } from "@nous-research/ui/hooks/use-toast";
|
||||
import { Toast } from "@nous-research/ui/ui/components/toast";
|
||||
|
|
@ -133,21 +136,79 @@ export default function SkillsPage() {
|
|||
const { t } = useI18n();
|
||||
const { setAfterTitle, setEnd } = usePageHeader();
|
||||
|
||||
// ── Profile scoping ──
|
||||
// The dashboard process runs under ONE profile, but skills/toolsets are
|
||||
// per-profile state. Without an explicit selector, users who "activated"
|
||||
// a profile on the Profiles page (which only affects FUTURE CLI/gateway
|
||||
// runs) toggled skills here and silently wrote into the dashboard's own
|
||||
// profile. The selector makes the write target explicit and deep-linkable
|
||||
// via /skills?profile=<name>.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [profiles, setProfiles] = useState<ProfileInfo[]>([]);
|
||||
const [currentProfile, setCurrentProfile] = useState<string>("");
|
||||
const urlProfile = searchParams.get("profile") ?? "";
|
||||
// "" = the dashboard's own profile (legacy behavior).
|
||||
const selectedProfile = urlProfile;
|
||||
|
||||
const setSelectedProfile = useCallback(
|
||||
(name: string) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (name) next.set("profile", name);
|
||||
else next.delete("profile");
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
// The profile actually being managed, for display purposes.
|
||||
const managedProfile = selectedProfile || currentProfile || "default";
|
||||
const managingOtherProfile =
|
||||
!!selectedProfile && selectedProfile !== currentProfile;
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
// Profile list + the dashboard's own profile, for the selector. Failure
|
||||
// leaves the selector hidden — the page still works profile-unscoped.
|
||||
api
|
||||
.getProfiles()
|
||||
.then((res) => setProfiles(res.profiles))
|
||||
.catch(() => {});
|
||||
api
|
||||
.getActiveProfile()
|
||||
.then((info) => setCurrentProfile(info.current || "default"))
|
||||
.catch(() => setCurrentProfile("default"));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Promise-chain shape: setState fires only inside async callbacks so the
|
||||
// effect body stays lint-clean (react-hooks/set-state-in-effect). On a
|
||||
// profile switch the old list stays visible until the new one arrives.
|
||||
let cancelled = false;
|
||||
Promise.all([
|
||||
api.getSkills(selectedProfile || undefined),
|
||||
api.getToolsets(selectedProfile || undefined),
|
||||
])
|
||||
.then(([s, tsets]) => {
|
||||
if (cancelled) return;
|
||||
setSkills(s);
|
||||
setToolsets(tsets);
|
||||
})
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
.catch(() => !cancelled && showToast(t.common.loading, "error"))
|
||||
.finally(() => !cancelled && setLoading(false));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedProfile]);
|
||||
|
||||
/* ---- Toggle skill ---- */
|
||||
const handleToggleSkill = async (skill: SkillInfo) => {
|
||||
setTogglingSkills((prev) => new Set(prev).add(skill.name));
|
||||
try {
|
||||
await api.toggleSkill(skill.name, !skill.enabled);
|
||||
await api.toggleSkill(skill.name, !skill.enabled, selectedProfile || undefined);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
|
||||
|
|
@ -233,10 +294,37 @@ export default function SkillsPage() {
|
|||
return;
|
||||
}
|
||||
setAfterTitle(
|
||||
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-2 whitespace-nowrap text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf
|
||||
.replace("{enabled}", String(enabledCount))
|
||||
.replace("{total}", String(skills.length))}
|
||||
{profiles.length > 1 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<select
|
||||
aria-label={t.skills.profileSelector ?? "Profile"}
|
||||
className="h-6 rounded-none border border-border bg-background px-1 text-xs text-foreground"
|
||||
value={selectedProfile}
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||
setSelectedProfile(e.target.value)
|
||||
}
|
||||
>
|
||||
<option value="">
|
||||
{(t.skills.currentProfile ?? "current ({name})").replace(
|
||||
"{name}",
|
||||
currentProfile || "default",
|
||||
)}
|
||||
</option>
|
||||
{profiles
|
||||
.filter((p) => p.name !== currentProfile)
|
||||
.map((p) => (
|
||||
<option key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
|
|
@ -265,7 +353,19 @@ export default function SkillsPage() {
|
|||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
};
|
||||
}, [enabledCount, loading, search, setAfterTitle, setEnd, skills.length, t]);
|
||||
}, [
|
||||
enabledCount,
|
||||
loading,
|
||||
search,
|
||||
setAfterTitle,
|
||||
setEnd,
|
||||
skills.length,
|
||||
t,
|
||||
profiles,
|
||||
selectedProfile,
|
||||
currentProfile,
|
||||
setSelectedProfile,
|
||||
]);
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
|
|
@ -291,6 +391,18 @@ export default function SkillsPage() {
|
|||
<PluginSlot name="skills:top" />
|
||||
<Toast toast={toast} />
|
||||
|
||||
{managingOtherProfile && (
|
||||
<div className="flex items-center gap-2 border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-300">
|
||||
<Users className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
{(
|
||||
t.skills.managingProfile ??
|
||||
"Managing profile “{name}” — toggles apply to that profile, not this dashboard’s."
|
||||
).replace("{name}", managedProfile)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-start gap-4">
|
||||
<aside aria-label={t.skills.title} className="sm:w-56 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-0">
|
||||
|
|
@ -540,13 +652,14 @@ export default function SkillsPage() {
|
|||
)}
|
||||
</>
|
||||
) : (
|
||||
<HubBrowser showToast={showToast} />
|
||||
<HubBrowser showToast={showToast} profile={selectedProfile || undefined} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{configToolset && (
|
||||
<ToolsetConfigDrawer
|
||||
toolset={configToolset}
|
||||
profile={selectedProfile || undefined}
|
||||
onClose={() => setConfigToolset(null)}
|
||||
onChanged={() => void refreshToolsets()}
|
||||
/>
|
||||
|
|
@ -668,8 +781,11 @@ const SEVERITY_TONE: Record<string, "destructive" | "warning" | "secondary" | "o
|
|||
|
||||
function HubBrowser({
|
||||
showToast,
|
||||
profile,
|
||||
}: {
|
||||
showToast: (msg: string, kind: "success" | "error") => void;
|
||||
/** Optional profile scoping installs + installed-state badges. */
|
||||
profile?: string;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SkillHubResult[]>([]);
|
||||
|
|
@ -699,7 +815,7 @@ function HubBrowser({
|
|||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.getSkillHubSources()
|
||||
.getSkillHubSources(profile)
|
||||
.then((r) => {
|
||||
if (cancelled) return;
|
||||
setSources(r.sources);
|
||||
|
|
@ -715,7 +831,7 @@ function HubBrowser({
|
|||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
}, [profile]);
|
||||
|
||||
/* ---- Search ---- */
|
||||
const runSearch = useCallback(async () => {
|
||||
|
|
@ -725,7 +841,7 @@ function HubBrowser({
|
|||
setSearched(true);
|
||||
const t0 = performance.now();
|
||||
try {
|
||||
const r = await api.searchSkillsHub(q);
|
||||
const r = await api.searchSkillsHub(q, "all", 20, profile);
|
||||
setResults(r.results);
|
||||
setSourceCounts(r.source_counts || {});
|
||||
setTimedOut(r.timed_out || []);
|
||||
|
|
@ -739,7 +855,7 @@ function HubBrowser({
|
|||
setSearchMs(Math.round(performance.now() - t0));
|
||||
setSearching(false);
|
||||
}
|
||||
}, [query, showToast]);
|
||||
}, [query, showToast, profile]);
|
||||
|
||||
/* ---- Poll a spawned action's log until it exits ---- */
|
||||
useEffect(() => {
|
||||
|
|
@ -757,7 +873,7 @@ function HubBrowser({
|
|||
} else {
|
||||
// Install finished — refresh installed-state so badges update.
|
||||
api
|
||||
.getSkillHubSources()
|
||||
.getSkillHubSources(profile)
|
||||
.then((r) => !cancelled && setInstalled(r.installed))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
|
@ -770,12 +886,12 @@ function HubBrowser({
|
|||
cancelled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [action]);
|
||||
}, [action, profile]);
|
||||
|
||||
const install = useCallback(
|
||||
async (identifier: string) => {
|
||||
try {
|
||||
const res = await api.installSkillFromHub(identifier);
|
||||
const res = await api.installSkillFromHub(identifier, profile);
|
||||
showToast(`Installing ${identifier}…`, "success");
|
||||
setActionLog([]);
|
||||
setActionRunning(true);
|
||||
|
|
@ -785,12 +901,12 @@ function HubBrowser({
|
|||
showToast(`Install failed: ${e}`, "error");
|
||||
}
|
||||
},
|
||||
[showToast],
|
||||
[showToast, profile],
|
||||
);
|
||||
|
||||
const updateAll = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.updateSkillsFromHub();
|
||||
const res = await api.updateSkillsFromHub(profile);
|
||||
showToast("Updating installed skills…", "success");
|
||||
setActionLog([]);
|
||||
setActionRunning(true);
|
||||
|
|
@ -798,7 +914,7 @@ function HubBrowser({
|
|||
} catch (e) {
|
||||
showToast(`Update failed: ${e}`, "error");
|
||||
}
|
||||
}, [showToast]);
|
||||
}, [showToast, profile]);
|
||||
|
||||
const isInstalled = useCallback(
|
||||
(identifier: string) => Boolean(installed[identifier]),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue