feat(dashboard): full tool backend configuration in the GUI (#40418)

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 <KEY>` — 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 <key>`; 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.
This commit is contained in:
Teknium
2026-06-06 07:45:36 -07:00
committed by GitHub
parent e6de6dd559
commit 2bf0a6e760
7 changed files with 866 additions and 0 deletions
+24
View File
@@ -14740,12 +14740,36 @@ Examples:
help="Platform to apply to (default: cli)",
)
# hermes tools post-setup <key>
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
+62
View File
@@ -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 <key>`` — 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 <key>")
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]:
+101
View File
@@ -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 <key>`` 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
# ---------------------------------------------------------------------------