From 2bf0a6e76083f1e46a766ac75b75c42431e18528 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 6 Jun 2026 07:45:36 -0700 Subject: [PATCH] feat(dashboard): full tool backend configuration in the GUI (#40418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicate the `hermes tools` configurator in the dashboard Skills → Toolsets view. Each toolset now opens a config drawer that covers the full lifecycle the CLI offers: enable/disable, pick a provider/backend, enter and save API keys, and run a provider's post-setup install hook with a live log tail. The toolset view was previously read+toggle only — the provider matrix and key-status endpoints existed but the page never called them, and there was no way to save a key or run a backend install (npm/pip/binary) from the browser. Backend: - New CLI subcommand `hermes tools post-setup ` — non-interactive, scriptable target that runs a provider's install hook (agent_browser, camofox, cua_driver, kittentts, piper, ddgs, spotify, langfuse, xai_grok). Validated against valid_post_setup_keys() so an arbitrary key can't drive _run_post_setup. - PUT /api/tools/toolsets/{name}/env — save API keys to ~/.hermes/.env via save_env_value (same store the CLI writes), validated against the toolset category's env-var allowlist; blank values skipped. - POST /api/tools/toolsets/{name}/post-setup — spawn-action that runs `hermes tools post-setup `; frontend tails the log via the existing /api/actions/tools-post-setup/status. Registered in _ACTION_LOG_FILES. Frontend: - New ToolsetConfigDrawer component (provider radios, password key inputs with saved-state, get-a-key links, Run-setup + live install log). Toolset cards get a Configure button + the drawer also exposes the enable toggle. - api.ts: toggleToolset, getToolsetConfig, selectToolsetProvider, saveToolsetEnv, runToolsetPostSetup + ToolsetConfig/Provider/EnvVar/ EnvResult types. Validation: 56 admin-endpoint tests pass (10 new: env save w/ CLI parity + allowlist reject + blank-skip, post-setup spawn validation, auth gate); 232 web_server tests pass; web npm run build + eslint clean; HTTP E2E exercises save-key (CLI reads it back) and spawn+poll post-setup to exit 0. --- hermes_cli/main.py | 24 + hermes_cli/tools_config.py | 62 +++ hermes_cli/web_server.py | 101 ++++ .../test_dashboard_admin_endpoints.py | 129 +++++ web/src/components/ToolsetConfigDrawer.tsx | 448 ++++++++++++++++++ web/src/lib/api.ts | 73 +++ web/src/pages/SkillsPage.tsx | 29 ++ 7 files changed, 866 insertions(+) create mode 100644 web/src/components/ToolsetConfigDrawer.tsx diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4316bc1f535..4c0c733449b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -14740,12 +14740,36 @@ Examples: help="Platform to apply to (default: cli)", ) + # hermes tools post-setup + tools_postsetup_p = tools_sub.add_parser( + "post-setup", + help="Run a provider's post-setup install hook (npm/pip/binary)", + description=( + "Run the install/bootstrap hook a tool backend declares — the\n" + "same step `hermes tools` runs after you pick a provider that\n" + "needs extra dependencies (browser Chromium, Camofox, cua-driver,\n" + "KittenTTS/Piper, ddgs, Spotify, Langfuse, xAI). Stable,\n" + "non-interactive target the dashboard spawns to drive backend\n" + "setup. Keys: agent_browser, camofox, cua_driver, kittentts,\n" + "piper, ddgs, spotify, langfuse, xai_grok." + ), + ) + tools_postsetup_p.add_argument( + "post_setup_key", + metavar="KEY", + help="Post-setup hook key (e.g. agent_browser, camofox, kittentts)", + ) + def cmd_tools(args): action = getattr(args, "tools_action", None) if action in {"list", "disable", "enable"}: from hermes_cli.tools_config import tools_disable_enable_command tools_disable_enable_command(args) + elif action == "post-setup": + from hermes_cli.tools_config import run_post_setup_command + + sys.exit(run_post_setup_command(args)) else: _require_tty("tools") from hermes_cli.tools_config import tools_command diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 50f1f9f8607..72fb824a560 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1168,6 +1168,68 @@ def _run_post_setup(post_setup_key: str): _print_info(" xAI will remain inactive until credentials are configured.") +def valid_post_setup_keys() -> Set[str]: + """Return the set of post-setup keys declared by any visible provider. + + Collected from ``TOOL_CATEGORIES`` plus the plugin-registered web / + image-gen / video-gen / browser providers (which can also carry a + ``post_setup``). This is the allowlist the ``hermes tools post-setup`` + command and the dashboard post-setup endpoint validate against, so a + caller can't drive ``_run_post_setup`` with an arbitrary key. + """ + keys: Set[str] = set() + for cat in TOOL_CATEGORIES.values(): + for prov in cat.get("providers", []): + ps = prov.get("post_setup") + if ps: + keys.add(ps) + # Plugin-registered providers can declare their own post_setup hooks. + for builder in ( + _plugin_web_search_providers, + _plugin_image_gen_providers, + _plugin_video_gen_providers, + _plugin_browser_providers, + ): + try: + for prov in builder(): + ps = prov.get("post_setup") + if ps: + keys.add(ps) + except Exception: # pragma: no cover — defensive; plugins optional + continue + return keys + + +def run_post_setup_command(args) -> int: + """``hermes tools post-setup `` — non-interactive post-setup runner. + + Runs the install/bootstrap hook a provider declares (npm install for + browser/Camofox, pip install for kittentts/piper/ddgs, cua-driver fetch, + etc.). This is the stable, scriptable target the dashboard spawns so the + GUI can drive backend setup without re-implementing the install logic. + Returns a process exit code (0 ok, 2 unknown key). + """ + key = getattr(args, "post_setup_key", None) + if not key: + _print_error("Usage: hermes tools post-setup ") + return 2 + valid = valid_post_setup_keys() + if key not in valid: + _print_error( + f"Unknown post-setup key: {key!r}. " + f"Valid keys: {', '.join(sorted(valid)) or '(none)'}" + ) + return 2 + _print_info(f"Running post-setup hook: {key}") + try: + _run_post_setup(key) + except Exception as exc: # pragma: no cover — defensive + _print_error(f"Post-setup failed: {exc}") + return 1 + _print_success(f"Post-setup '{key}' complete") + return 0 + + # ─── Platform / Toolset Helpers ─────────────────────────────────────────────── def _get_enabled_platforms() -> List[str]: diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 6a3ed07344b..3373a571bbd 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1145,6 +1145,7 @@ _ACTION_LOG_FILES: Dict[str, str] = { "prompt-size": "action-prompt-size.log", "dump": "action-dump.log", "config-migrate": "action-config-migrate.log", + "tools-post-setup": "action-tools-post-setup.log", } # ``name`` → most recently spawned Popen handle. Used so ``status`` can @@ -7684,6 +7685,106 @@ async def select_toolset_provider(name: str, body: ToolsetProviderSelect): return {"ok": True, "name": name, "provider": body.provider} +class ToolsetEnvUpdate(BaseModel): + env: Dict[str, str] + + +@app.put("/api/tools/toolsets/{name}/env") +async def save_toolset_env(name: str, body: ToolsetEnvUpdate): + """Persist API keys for a toolset's provider env vars. + + Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` — + the same store ``hermes tools`` writes when it prompts for keys. Keys are + validated against the env-var allowlist for the toolset's category (the + union of every visible provider's ``env_vars``), so the GUI can't write an + arbitrary env var through this endpoint. A blank value is treated as + "leave unchanged" and skipped. Returns the saved/skipped key lists and the + refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys. + """ + from hermes_cli.tools_config import ( + TOOL_CATEGORIES, + _get_effective_configurable_toolsets, + _visible_providers, + ) + from hermes_cli.config import get_env_value, save_env_value + + valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} + 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"]) + + 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) + + status = {k: bool(get_env_value(k)) for k in allowed} + return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status} + + +class ToolsetPostSetup(BaseModel): + key: str + + +@app.post("/api/tools/toolsets/{name}/post-setup") +async def run_toolset_post_setup(name: str, body: ToolsetPostSetup): + """Spawn a provider's post-setup install hook as a background action. + + Post-setup hooks (npm install for browser/Camofox, pip install for + KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and + text-output, so this follows the spawn-action pattern: it launches + ``hermes tools post-setup `` and the frontend tails the log via + ``GET /api/actions/tools-post-setup/status``. The ``key`` is validated + against the declared post-setup allowlist before spawning. Returns 400 + for unknown toolset or post-setup key. + """ + from hermes_cli.tools_config import ( + _get_effective_configurable_toolsets, + valid_post_setup_keys, + ) + + valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()} + if name not in valid_ts: + raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}") + + if body.key not in valid_post_setup_keys(): + raise HTTPException( + status_code=400, detail=f"Unknown post-setup key: {body.key}" + ) + + try: + proc = _spawn_hermes_action( + ["tools", "post-setup", body.key], "tools-post-setup" + ) + except Exception as exc: + _log.exception("Failed to spawn tools post-setup") + raise HTTPException( + status_code=500, detail=f"Failed to run post-setup: {exc}" + ) + return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key} + + # --------------------------------------------------------------------------- # Raw YAML config endpoint # --------------------------------------------------------------------------- diff --git a/tests/hermes_cli/test_dashboard_admin_endpoints.py b/tests/hermes_cli/test_dashboard_admin_endpoints.py index 04ccc7eb299..df21d2fd56c 100644 --- a/tests/hermes_cli/test_dashboard_admin_endpoints.py +++ b/tests/hermes_cli/test_dashboard_admin_endpoints.py @@ -794,3 +794,132 @@ class TestDebugShareEndpoint: ) assert r.status_code == 401 + +class TestToolsConfigEndpoints: + """Provider selection, API-key save, and post-setup spawn for toolsets — + the dashboard surface that replicates the `hermes tools` configurator.""" + + @pytest.fixture(autouse=True) + def _setup(self, _isolate_hermes_home): + self.client, self.header = _client() + + def test_list_toolsets_shape(self): + r = self.client.get("/api/tools/toolsets") + assert r.status_code == 200 + rows = r.json() + assert isinstance(rows, list) and rows + row = rows[0] + for k in ("name", "label", "enabled", "configured", "tools"): + assert k in row + + def test_toolset_config_provider_matrix(self): + # `web` has a TOOL_CATEGORIES entry → providers list populated. + r = self.client.get("/api/tools/toolsets/web/config") + assert r.status_code == 200 + body = r.json() + assert body["has_category"] is True + assert isinstance(body["providers"], list) + + def test_unknown_toolset_config_400(self): + r = self.client.get("/api/tools/toolsets/not_a_toolset/config") + assert r.status_code == 400 + + def test_save_env_writes_key_and_validates_allowlist(self): + from hermes_cli.config import get_env_value + + cfg = self.client.get("/api/tools/toolsets/web/config").json() + # Find a real env-var key from the visible provider matrix. + key = None + for prov in cfg["providers"]: + for e in prov.get("env_vars", []): + key = e["key"] + break + if key: + break + if not key: + pytest.skip("no env-var-bearing web provider in this build") + + r = self.client.put( + "/api/tools/toolsets/web/env", json={"env": {key: "test-secret-123"}} + ) + assert r.status_code == 200, r.text + body = r.json() + assert key in body["saved"] + assert body["is_set"][key] is True + # CLI-config parity: the key landed in the .env store the CLI reads. + assert get_env_value(key) == "test-secret-123" + + def test_save_env_rejects_unknown_key(self): + r = self.client.put( + "/api/tools/toolsets/web/env", + json={"env": {"TOTALLY_BOGUS_KEY": "x"}}, + ) + assert r.status_code == 400 + + def test_save_env_blank_value_skipped(self): + cfg = self.client.get("/api/tools/toolsets/web/config").json() + key = None + for prov in cfg["providers"]: + for e in prov.get("env_vars", []): + key = e["key"] + break + if key: + break + if not key: + pytest.skip("no env-var-bearing web provider in this build") + r = self.client.put( + "/api/tools/toolsets/web/env", json={"env": {key: " "}} + ) + assert r.status_code == 200 + assert key in r.json()["skipped"] + + def test_post_setup_unknown_key_400(self): + r = self.client.post( + "/api/tools/toolsets/browser/post-setup", json={"key": "bogus"} + ) + assert r.status_code == 400 + + def test_post_setup_unknown_toolset_400(self): + r = self.client.post( + "/api/tools/toolsets/not_a_toolset/post-setup", + json={"key": "agent_browser"}, + ) + assert r.status_code == 400 + + def test_post_setup_spawns_action(self, monkeypatch): + import hermes_cli.web_server as ws + + spawned = {} + + class _FakeProc: + pid = 4321 + + def _fake_spawn(subcommand, name): + spawned["subcommand"] = subcommand + spawned["name"] = name + return _FakeProc() + + monkeypatch.setattr(ws, "_spawn_hermes_action", _fake_spawn) + r = self.client.post( + "/api/tools/toolsets/browser/post-setup", + json={"key": "agent_browser"}, + ) + assert r.status_code == 200, r.text + body = r.json() + assert body["name"] == "tools-post-setup" + assert body["pid"] == 4321 + assert spawned["subcommand"] == ["tools", "post-setup", "agent_browser"] + + def test_endpoints_require_session_token(self): + for method, path, payload in [ + ("get", "/api/tools/toolsets/web/config", None), + ("put", "/api/tools/toolsets/web/env", {"env": {}}), + ("post", "/api/tools/toolsets/web/post-setup", {"key": "ddgs"}), + ]: + fn = getattr(self.client, method) + kwargs = {"headers": {self.header: "wrong-token"}} + if payload is not None: + kwargs["json"] = payload + r = fn(path, **kwargs) + assert r.status_code == 401, f"{method} {path} not gated" + diff --git a/web/src/components/ToolsetConfigDrawer.tsx b/web/src/components/ToolsetConfigDrawer.tsx new file mode 100644 index 00000000000..42e58d589f5 --- /dev/null +++ b/web/src/components/ToolsetConfigDrawer.tsx @@ -0,0 +1,448 @@ +import { useCallback, useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, ExternalLink, Loader2, Terminal, X } from "lucide-react"; +import { api } from "@/lib/api"; +import type { + ToolsetConfig, + ToolsetInfo, + ToolsetProvider, +} from "@/lib/api"; +import { useToast } from "@nous-research/ui/hooks/use-toast"; +import { Button } from "@nous-research/ui/ui/components/button"; +import { Input } from "@nous-research/ui/ui/components/input"; +import { Label } from "@nous-research/ui/ui/components/label"; +import { Badge } from "@nous-research/ui/ui/components/badge"; +import { Switch } from "@nous-research/ui/ui/components/switch"; +import { Spinner } from "@nous-research/ui/ui/components/spinner"; +import { Toast } from "@nous-research/ui/ui/components/toast"; +import { cn, themedBody } from "@/lib/utils"; + +interface Props { + /** The toolset whose backends are being configured. */ + toolset: ToolsetInfo; + onClose: () => void; + /** Called after a toggle/provider/key change so the parent grid refreshes. */ + onChanged: () => void; +} + +/** + * Full configuration surface for a single toolset's backends — the dashboard + * equivalent of selecting a toolset in the `hermes tools` curses UI: toggle + * 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) { + const { toast, showToast } = useToast(); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + const [enabled, setEnabled] = useState(toolset.enabled); + const [toggling, setToggling] = useState(false); + const [selecting, setSelecting] = useState(null); + const [activeProvider, setActiveProvider] = useState(null); + // Per-env-var draft input values, keyed by env var name. + const [drafts, setDrafts] = useState>({}); + const [savingProvider, setSavingProvider] = useState(null); + const [isSet, setIsSet] = useState>({}); + + // Post-setup install log tail state. + const [postSetupRunning, setPostSetupRunning] = useState(false); + const [postSetupLog, setPostSetupLog] = useState([]); + const [postSetupKey, setPostSetupKey] = useState(null); + // Bumped each time a post-setup is kicked off, to (re)trigger the poll + // effect below. Mirrors the SkillsPage HubBrowser action-poll pattern so + // the recursive timer lives inside the effect (lint-clean — no ref + // mutation, no self-referencing memo). + const [postSetupTrigger, setPostSetupTrigger] = useState(0); + + const loadConfig = useCallback(() => { + // Promise-chain shape (not async/await with a leading synchronous + // setLoading) so callers in a useEffect don't trip + // react-hooks/set-state-in-effect — setState only fires inside the + // async .then/.catch/.finally callbacks. + return api + .getToolsetConfig(toolset.name) + .then((cfg) => { + setConfig(cfg); + setActiveProvider(cfg.active_provider); + const seed: Record = {}; + for (const p of cfg.providers) { + for (const e of p.env_vars) seed[e.key] = e.is_set; + } + setIsSet(seed); + }) + .catch(() => showToast("Failed to load toolset config", "error")) + .finally(() => setLoading(false)); + }, [toolset.name, showToast]); + + useEffect(() => { + void loadConfig(); + }, [loadConfig]); + + // Poll the post-setup action's log until it exits. Driven by + // postSetupTrigger; the recursive timer + cleanup live entirely inside the + // effect (matches the SkillsPage HubBrowser pattern — lint-clean). + useEffect(() => { + if (postSetupTrigger === 0) return; + let cancelled = false; + let timer: ReturnType | null = null; + const poll = async () => { + try { + const st = await api.getActionStatus("tools-post-setup", 300); + if (cancelled) return; + setPostSetupLog(st.lines); + if (st.running) { + timer = setTimeout(() => void poll(), 1200); + } else { + setPostSetupRunning(false); + const ok = st.exit_code === 0; + showToast( + ok ? "Post-setup complete" : "Post-setup finished with errors", + ok ? "success" : "error", + ); + // Refresh — a backend may now report itself configured/available. + void loadConfig(); + onChanged(); + } + } catch { + if (!cancelled) { + setPostSetupRunning(false); + showToast("Lost track of the post-setup process", "error"); + } + } + }; + // Small delay so the spawned action has a log file to read. + timer = setTimeout(() => void poll(), 800); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [postSetupTrigger, showToast, loadConfig, onChanged]); + + const handleToggle = async (next: boolean) => { + setToggling(true); + try { + await api.toggleToolset(toolset.name, next); + setEnabled(next); + showToast( + `${toolset.label || toolset.name} ${next ? "enabled" : "disabled"}`, + "success", + ); + onChanged(); + } catch { + showToast("Failed to toggle toolset", "error"); + } finally { + setToggling(false); + } + }; + + const handleSelectProvider = async (provider: ToolsetProvider) => { + setSelecting(provider.name); + try { + await api.selectToolsetProvider(toolset.name, provider.name); + setActiveProvider(provider.name); + showToast(`Provider set to ${provider.name}`, "success"); + onChanged(); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to select provider", + "error", + ); + } finally { + setSelecting(null); + } + }; + + const handleSaveKeys = async (provider: ToolsetProvider) => { + const env: Record = {}; + for (const e of provider.env_vars) { + const v = drafts[e.key]; + if (v && v.trim()) env[e.key] = v.trim(); + } + if (Object.keys(env).length === 0) { + showToast("Enter at least one value to save", "error"); + return; + } + setSavingProvider(provider.name); + try { + const res = await api.saveToolsetEnv(toolset.name, env); + setIsSet((prev) => ({ ...prev, ...res.is_set })); + // Clear saved drafts so the inputs reset to the "saved" placeholder. + setDrafts((prev) => { + const next = { ...prev }; + for (const k of res.saved) delete next[k]; + return next; + }); + showToast( + res.saved.length + ? `Saved ${res.saved.length} key${res.saved.length > 1 ? "s" : ""}` + : "Nothing to save", + "success", + ); + onChanged(); + } catch (e) { + showToast( + e instanceof Error ? e.message : "Failed to save keys", + "error", + ); + } finally { + setSavingProvider(null); + } + }; + + const handleRunPostSetup = async (provider: ToolsetProvider) => { + if (!provider.post_setup) return; + setPostSetupRunning(true); + setPostSetupLog([]); + setPostSetupKey(provider.post_setup); + try { + await api.runToolsetPostSetup(toolset.name, provider.post_setup); + // Bump the trigger so the poll effect (re)starts tailing the log. + setPostSetupTrigger((n) => n + 1); + } catch (e) { + setPostSetupRunning(false); + showToast( + e instanceof Error ? e.message : "Failed to start post-setup", + "error", + ); + } + }; + + const labelText = toolset.label?.trim() || toolset.name; + + return createPortal( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ + + {/* Header — toolset identity + enable toggle */} +
+
+ + {labelText} + + + {enabled ? "Active" : "Inactive"} + +
+

+ {toolset.description} +

+
+ void handleToggle(v)} + disabled={toggling} + aria-label="Enable toolset" + /> + + {enabled ? "Enabled for the agent" : "Disabled"} + +
+
+ + {/* Body — provider matrix */} +
+ {loading ? ( +
+ +
+ ) : !config?.has_category ? ( +

+ This toolset has no configurable backends — toggle it on or off + above. It works with no provider selection or API keys. +

+ ) : config.providers.length === 0 ? ( +

+ No providers are available for this toolset in this install. +

+ ) : ( + config.providers.map((provider) => { + const isActive = provider.name === activeProvider; + return ( +
+
+
+ + {provider.name} + + {provider.badge && ( + + {provider.badge} + + )} + {provider.requires_nous_auth && ( + + Nous Portal + + )} +
+ {isActive ? ( + + Selected + + ) : ( + + )} +
+ {provider.tag && ( +

+ {provider.tag} +

+ )} + + {/* API key inputs */} + {provider.env_vars.length > 0 && ( +
+ {provider.env_vars.map((ev) => ( +
+
+ + {isSet[ev.key] && ( + + Saved + + )} +
+ + setDrafts((prev) => ({ + ...prev, + [ev.key]: e.target.value, + })) + } + /> + {ev.url && ( + + Get a key + + )} +
+ ))} + +
+ )} + + {/* Post-setup install hook */} + {provider.post_setup && ( +
+

+ This backend needs a one-time install + {" "} + + ({provider.post_setup}) + + . Runs on this host — may take a few minutes. +

+ +
+ )} +
+ ); + }) + )} + + {/* Post-setup live log */} + {(postSetupRunning || postSetupLog.length > 0) && ( +
+
+ + + post-setup: {postSetupKey} + + {postSetupRunning && ( + + )} +
+
+                {postSetupLog.length ? postSetupLog.join("\n") : "Starting…"}
+              
+
+ )} +
+
+ +
, + document.body, + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0452ee46103..cbcb7fe1440 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -513,6 +513,46 @@ export const api = { body: JSON.stringify({ name, enabled }), }), getToolsets: () => fetchJSON("/api/tools/toolsets"), + toggleToolset: (name: string, enabled: boolean) => + fetchJSON<{ ok: boolean; name: string; enabled: boolean }>( + `/api/tools/toolsets/${encodeURIComponent(name)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ), + getToolsetConfig: (name: string) => + fetchJSON( + `/api/tools/toolsets/${encodeURIComponent(name)}/config`, + ), + selectToolsetProvider: (name: string, provider: 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 }), + }, + ), + saveToolsetEnv: (name: string, env: Record) => + fetchJSON( + `/api/tools/toolsets/${encodeURIComponent(name)}/env`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ env }), + }, + ), + runToolsetPostSetup: (name: string, key: string) => + fetchJSON( + `/api/tools/toolsets/${encodeURIComponent(name)}/post-setup`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }, + ), // Session search (FTS5) searchSessions: (q: string) => @@ -1619,6 +1659,39 @@ export interface ToolsetInfo { tools: string[]; } +export interface ToolsetProviderEnvVar { + key: string; + prompt: string; + url: string | null; + default: string | null; + is_set: boolean; +} + +export interface ToolsetProvider { + name: string; + badge: string; + tag: string; + env_vars: ToolsetProviderEnvVar[]; + post_setup: string | null; + requires_nous_auth: boolean; + is_active: boolean; +} + +export interface ToolsetConfig { + name: string; + has_category: boolean; + providers: ToolsetProvider[]; + active_provider: string | null; +} + +export interface ToolsetEnvResult { + ok: boolean; + name: string; + saved: string[]; + skipped: string[]; + is_set: Record; +} + export interface SessionSearchResult { session_id: string; snippet: string; diff --git a/web/src/pages/SkillsPage.tsx b/web/src/pages/SkillsPage.tsx index c21a3e481d0..4ece3105f76 100644 --- a/web/src/pages/SkillsPage.tsx +++ b/web/src/pages/SkillsPage.tsx @@ -36,6 +36,7 @@ import type { SkillHubPreview, SkillHubScan, } from "@/lib/api"; +import { ToolsetConfigDrawer } from "@/components/ToolsetConfigDrawer"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; @@ -127,6 +128,7 @@ export default function SkillsPage() { const [view, setView] = useState<"skills" | "toolsets" | "hub">("skills"); const [activeCategory, setActiveCategory] = useState(null); const [togglingSkills, setTogglingSkills] = useState>(new Set()); + const [configToolset, setConfigToolset] = useState(null); const { toast, showToast } = useToast(); const { t } = useI18n(); const { setAfterTitle, setEnd } = usePageHeader(); @@ -166,6 +168,16 @@ export default function SkillsPage() { } }; + /* ---- Refresh toolsets after a config change ---- */ + const refreshToolsets = async () => { + try { + const tsets = await api.getToolsets(); + setToolsets(tsets); + } catch { + /* non-fatal: the drawer already toasted on the failing write */ + } + }; + /* ---- Derived data ---- */ const lowerSearch = search.toLowerCase(); const isSearching = search.trim().length > 0; @@ -508,6 +520,16 @@ export default function SkillsPage() { : t.skills.disabledForCli} )} +
+ +
@@ -522,6 +544,13 @@ export default function SkillsPage() { )} + {configToolset && ( + setConfigToolset(null)} + onChanged={() => void refreshToolsets()} + /> + )} );