diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index a77b639b284c..56ff7cec1644 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -165,6 +165,20 @@ def _toolset_allowed_for_platform(ts_key: str, platform: str) -> bool: return allowed is None or platform in allowed +def _toolset_configuration_platform(ts_key: str, default: str = "cli") -> str: + """Return the platform a platform-less configuration UI should target. + + Most configurable toolsets retain the historical desktop/CLI target. A + toolset restricted away from that platform must instead be configured on + one of its supported platforms; otherwise the shared save helper correctly + drops it and the UI reports a successful no-op. + """ + allowed = _TOOLSET_PLATFORM_RESTRICTIONS.get(ts_key) + if not allowed or default in allowed: + return default + return sorted(allowed)[0] + + def _get_effective_configurable_toolsets(): """Return CONFIGURABLE_TOOLSETS + any plugin-provided toolsets. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 1fe07fefc059..3dbbfa67d6e5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -13700,29 +13700,43 @@ async def get_toolsets(profile: Optional[str] = None): from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, + _toolset_configuration_platform, _toolset_has_keys, gui_toolset_label, ) + from hermes_cli.platforms import platform_label from toolsets import resolve_toolset with _profile_scope(profile): config = load_config() - enabled_toolsets = _get_platform_tools( - config, - "cli", - include_default_mcp_servers=False, - ) + toolset_rows = _get_effective_configurable_toolsets() + target_platforms = { + _toolset_configuration_platform(name) for name, _, _ in toolset_rows + } + enabled_by_platform = { + platform: _get_platform_tools( + config, + platform, + include_default_mcp_servers=False, + ) + for platform in target_platforms + } result = [] - for name, label, desc in _get_effective_configurable_toolsets(): + for name, label, desc in toolset_rows: try: tools = sorted(set(resolve_toolset(name))) except Exception: tools = [] - is_enabled = name in enabled_toolsets + target_platform = _toolset_configuration_platform(name) + is_enabled = name in enabled_by_platform[target_platform] result.append({ "name": name, "label": gui_toolset_label(label), "description": desc, + "platform": target_platform, + "platform_label": gui_toolset_label( + platform_label(target_platform, target_platform) + ), "enabled": is_enabled, "available": is_enabled, "configured": _toolset_has_keys(name, config), @@ -13738,34 +13752,46 @@ class ToolsetToggle(BaseModel): @app.put("/api/tools/toolsets/{name}") async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None): - """Enable/disable a configurable toolset for the desktop (cli) platform. + """Enable/disable a configurable toolset for its configuration platform. - 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. Scoped to ``body.profile`` when provided. Returns 400 for - unknown toolset keys. + Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted + toolsets instead target their supported platform (for example, Discord's + native toolsets persist to ``platform_toolsets.discord``). The shared + ``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped + to ``body.profile`` when provided. Returns 400 for unknown toolset keys. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, _save_platform_tools, + _toolset_configuration_platform, ) valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} if name not in valid: raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + target_platform = _toolset_configuration_platform(name) with _profile_scope(body.profile or profile): config = load_config() enabled = set( - _get_platform_tools(config, "cli", include_default_mcp_servers=False) + _get_platform_tools( + config, + target_platform, + 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} + _save_platform_tools(config, target_platform, enabled) + return { + "ok": True, + "name": name, + "platform": target_platform, + "enabled": body.enabled, + } @app.get("/api/tools/toolsets/{name}/config") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 125b61a0359c..32e55af34c66 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4792,6 +4792,8 @@ class TestNewEndpoints: "name": "web", "label": "Web Search & Scraping", "description": "web_search, web_extract", + "platform": "cli", + "platform_label": "CLI", "enabled": True, "available": True, "configured": False, @@ -4801,6 +4803,8 @@ class TestNewEndpoints: "name": "skills", "label": "Skills", "description": "list, view, manage", + "platform": "cli", + "platform_label": "CLI", "enabled": True, "available": True, "configured": True, @@ -4810,6 +4814,8 @@ class TestNewEndpoints: "name": "memory", "label": "Memory", "description": "persistent memory across sessions", + "platform": "cli", + "platform_label": "CLI", "enabled": False, "available": False, "configured": True, @@ -4838,6 +4844,41 @@ class TestNewEndpoints: listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()} assert listing["x_search"]["enabled"] is False + def test_discord_toolsets_read_and_write_discord_platform(self): + """Platform-restricted toolsets must not be saved as successful CLI no-ops.""" + from hermes_cli.config import load_config + + listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()} + assert listing["discord"]["platform"] == "discord" + assert listing["discord"]["platform_label"] == "Discord" + assert listing["discord"]["enabled"] is False + + resp = self.client.put("/api/tools/toolsets/discord", json={"enabled": True}) + assert resp.status_code == 200 + assert resp.json() == { + "ok": True, + "name": "discord", + "platform": "discord", + "enabled": True, + } + + config = load_config() + assert "discord" in config["platform_toolsets"]["discord"] + assert "discord" not in config["platform_toolsets"].get("cli", []) + + listing = {t["name"]: t for t in self.client.get("/api/tools/toolsets").json()} + assert listing["discord"]["enabled"] is True + assert listing["discord_admin"]["enabled"] is False + + resp = self.client.put( + "/api/tools/toolsets/discord_admin", json={"enabled": True} + ) + assert resp.status_code == 200 + config = load_config() + assert {"discord", "discord_admin"} <= set( + config["platform_toolsets"]["discord"] + ) + def test_toggle_toolset_unknown_returns_400(self): resp = self.client.put( "/api/tools/toolsets/not_a_real_toolset", json={"enabled": True} diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx index bb5d6f87de64..86ee09c26de4 100644 --- a/web/src/components/ToolsetConfigDrawer.tsx +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -211,6 +211,7 @@ export function ToolsetConfigDrawer({ toolset, profile, onClose, onChanged }: Pr }; const labelText = toolset.label?.trim() || toolset.name; + const platformText = toolset.platform_label?.trim() || toolset.platform; return createPortal(
void handleToggle(v)} disabled={toggling} - aria-label="Enable toolset" + aria-label={`Enable toolset for ${platformText}`} /> - {enabled ? "Enabled for the agent" : "Disabled"} + {enabled + ? `Enabled for ${platformText}` + : `Disabled for ${platformText}`}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index d65a50153909..2691b577d229 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -732,7 +732,7 @@ export const api = { getToolsets: (profile?: string) => fetchJSON(`/api/tools/toolsets${profileQuery(profile)}`), toggleToolset: (name: string, enabled: boolean, profile?: string) => - fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( + fetchJSON<{ ok: boolean; name: string; platform: string; enabled: boolean }>( `/api/tools/toolsets/${encodeURIComponent(name)}`, { method: "PUT", @@ -2205,6 +2205,8 @@ export interface ToolsetInfo { name: string; label: string; description: string; + platform: string; + platform_label: string; enabled: boolean; configured: boolean; tools: string[]; diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index 8bc4a244f16a..039803b13497 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -201,7 +201,7 @@ export default function SkillsPage() { /* ---- Refresh toolsets after a config change ---- */ const refreshToolsets = async () => { try { - const tsets = await api.getToolsets(); + const tsets = await api.getToolsets(selectedProfile || undefined); setToolsets(tsets); } catch { /* non-fatal: the drawer already toasted on the failing write */