Files
hermes-agent/hermes_cli/web_routers/tools.py
T
Teknium 2776813df3 compat(plugins): temporary import-path shims for external plugins — ONE commit, revert on schedule
The Sep 2026 decomposition (PR #102117) makes internal import paths a non-API: names now live in
the focused modules that define them. This commit is the ONLY thing keeping the old paths alive,
so external plugins have time to update. It is deliberately a single, unsquashed commit:

    git revert <this sha>

removes every shim, stub and manifest at once on the announced date. Nothing in-tree may depend on
these pointers: scripts/check_compat_pointers.py (wired into lint.yml) fails CI if it does.

What it adds (see COMPAT_MANIFEST.md, compat_manifest.json):
- 332 facade modules get one delimited `PLUGIN-COMPAT` block appended at the end of the file
- 1,172 moved names resolved lazily via a module `__getattr__` (PEP 562) — never a top-level import,
  so no import cycles; facades that already had `__getattr__` get a chained one
- 592 third-party/stdlib names the old modules used to expose, with their original import statements
- 266 public definitions that had been deleted as unused, restored byte-for-byte from the pre-decomposition
  tree (+40 private helpers and 16 imports pulled in only because a restored definition needs them)
- 3 deleted modules recreated as re-export stubs (gateway/startup_watchdog, hermes_cli/observability/
  relay_runtime, tools/environments/modal_utils)
- private names (`_x`) get no pointer: they were never API (3,792 skipped)

Verified: all 335 touched modules import under a fresh HERMES_HOME and every manifest name resolves;
the lint reports zero in-tree uses; ruff clean; targeted suites unchanged.
2026-09-03 17:13:22 -07:00

707 lines
30 KiB
Python

"""Toolset / terminal-backend dashboard routes.
The toolset/terminal catalogs and helpers stay in web_server (some are defined
*after* this router's mount point) — reached via the late-binding seam so
monkeypatching on web_server stays authoritative.
"""
import asyncio
import shutil
import subprocess
import sys
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException
from hermes_cli.web_deps import late
from hermes_cli.web_server_profiles import _plugin_terminal_backend_rows
from starlette.concurrency import run_in_threadpool
from hermes_cli.web_models import (
TerminalBackendSelect, ToolsetEnvUpdate, ToolsetModelSelect, ToolsetPostSetup,
ToolsetProviderSelect, ToolsetToggle)
from hermes_cli.web_routers._common import (
_CONFIG_MUTATION_LOCK, _profile_cli_args, _profile_scope, _spawn_hermes_action,
config_write_scope, log as _log, scoped_to_thread, spawn_profile_action)
router = APIRouter()
load_config = late("load_config", "hermes_cli.config")
save_config = late("save_config", "hermes_cli.config")
def _env_value(name: str) -> str:
"""``get_env_value`` that never raises (empty string on any failure)."""
try:
from hermes_cli.config import get_env_value
return get_env_value(name) or ""
except Exception:
return ""
def _terminal_cfg_value(terminal_cfg: dict, key: str, env_var: str) -> str:
"""Read a terminal.* setting from config.yaml, falling back to its env var."""
value = terminal_cfg.get(key)
if value is not None and str(value).strip():
return str(value).strip()
return _env_value(env_var).strip()
def _terminal_backend_rows() -> List[Dict[str, str]]:
"""Built-in picker rows plus plugin-registered backends, computed per request
so a plugin installed after server start still shows up."""
from hermes_cli.web_server_profiles import _TERMINAL_BACKENDS
return [*_TERMINAL_BACKENDS, *_plugin_terminal_backend_rows()]
def _probe_docker_backend(_cfg) -> tuple:
if not shutil.which("docker"):
return ("needs_setup", "Docker CLI not found — install Docker Desktop or docker-ce.")
try:
proc = subprocess.run(
["docker", "info", "--format", "{{.ServerVersion}}"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2)
if proc.returncode == 0:
return ("ready", "")
return ("needs_setup", "Docker daemon not reachable — start Docker and retry.")
except subprocess.TimeoutExpired:
return ("needs_setup", "Docker daemon not responding (timed out).")
except Exception as exc:
return ("unavailable", f"Docker probe failed: {exc}")
def _probe_singularity_backend(_cfg) -> tuple:
if shutil.which("singularity") or shutil.which("apptainer"):
return ("ready", "")
return ("needs_setup", "Neither singularity nor apptainer found on PATH.")
def _probe_ssh_backend(terminal_cfg: dict) -> tuple:
host = _terminal_cfg_value(terminal_cfg, "ssh_host", "TERMINAL_SSH_HOST")
user = _terminal_cfg_value(terminal_cfg, "ssh_user", "TERMINAL_SSH_USER")
missing = [k for k, v in (("terminal.ssh_host", host), ("terminal.ssh_user", user)) if not v]
if missing:
return (
"needs_setup",
f"Set {' and '.join(missing)} in config.yaml (or the matching TERMINAL_SSH_* env vars).",
)
return ("ready", f"{user}@{host}")
def _probe_modal_backend(_cfg) -> tuple:
try:
from tools.tool_backend_helpers import has_direct_modal_credentials
if has_direct_modal_credentials():
return ("ready", "")
except Exception:
pass
if _env_value("MODAL_TOKEN_ID") and _env_value("MODAL_TOKEN_SECRET"):
return ("ready", "")
return (
"needs_setup",
"Modal credentials not found — set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET (or run `modal setup`).",
)
def _probe_daytona_backend(_cfg) -> tuple:
if _env_value("DAYTONA_API_KEY"):
return ("ready", "")
return ("needs_setup", "Set DAYTONA_API_KEY to use the Daytona backend.")
_BACKEND_PROBES = {
"local": lambda _cfg: ("ready", ""),
"docker": _probe_docker_backend,
"singularity": _probe_singularity_backend,
"ssh": _probe_ssh_backend,
"modal": _probe_modal_backend,
"daytona": _probe_daytona_backend,
}
def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple:
"""Return ``(status, detail)`` for one backend. Never raises."""
try:
probe = _BACKEND_PROBES.get(name)
if probe is not None:
return probe(terminal_cfg)
try:
from agent.terminal_env_registry import get_provider
provider = get_provider(name)
if provider is not None:
return provider.probe()
except Exception:
pass
return ("unavailable", f"Unknown backend: {name}")
except Exception as exc: # pragma: no cover — belt-and-braces guard
return ("unavailable", f"Probe failed: {exc}")
# Toolsets whose backends carry a selectable model catalog, mapped to the
# config.yaml section their `model` key lives in. Mirrors the CLI's
# post-selection model pickers in tools_config.py.
_MODEL_CATALOG_TOOLSETS = {"image_gen": "image_gen", "video_gen": "video_gen"}
def _resolve_toolset_model_plugin(ts_key: str, provider_row: dict) -> Optional[str]:
"""Map a provider picker row to its model-catalog plugin name.
Plugin-backed rows carry ``image_gen_plugin_name`` / ``video_gen_plugin_name``;
the managed "Nous Subscription" image row instead carries the legacy
``imagegen_backend: "fal"`` marker (same underlying FAL catalog).
"""
if ts_key == "image_gen":
return provider_row.get("image_gen_plugin_name") or (
"fal" if provider_row.get("imagegen_backend") else None)
if ts_key == "video_gen":
return provider_row.get("video_gen_plugin_name")
return None
def _toolset_model_catalog(ts_key: str, plugin_name: str):
"""Return ``(catalog_dict, default_model)`` for a toolset's plugin backend."""
from hermes_cli.tools_config import _plugin_image_gen_catalog, _plugin_video_gen_catalog
if ts_key == "image_gen":
return _plugin_image_gen_catalog(plugin_name)
return _plugin_video_gen_catalog(plugin_name)
def _category_providers(ts_key: str, config: dict) -> list:
"""Visible provider rows for a toolset's category (fresh entitlement read)."""
from hermes_cli.tools_config import TOOL_CATEGORIES, _visible_providers
cat = TOOL_CATEGORIES.get(ts_key)
return _visible_providers(cat, config, force_fresh=True) if cat else []
def _find_toolset_provider_row(
ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]:
"""Resolve a provider picker row by name, or the active row when omitted."""
from hermes_cli.tools_config import _is_provider_active
rows = _category_providers(ts_key, config)
if provider:
return next((p for p in rows if p.get("name") == provider), None)
return next((p for p in rows if _is_provider_active(p, config, force_fresh=True)), None)
def _require_known_toolset(name: str) -> None:
"""400 for toolset keys outside the effective configurable set."""
from hermes_cli.tools_config import _get_effective_configurable_toolsets
if name not in {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}:
raise _bad_request(f"Unknown toolset: {name}")
def _dict_section(config: dict, key: str) -> dict:
"""``config[key]`` as a dict, replacing a non-dict value in place."""
section = config.setdefault(key, {})
if not isinstance(section, dict):
section = {}
config[key] = section
return section
def _bad_request(detail: str) -> HTTPException:
return HTTPException(status_code=400, detail=detail)
def _no_models(name: str) -> dict:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
@router.get("/api/tools/toolsets")
async def get_toolsets(profile: Optional[str] = None):
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS, _get_effective_configurable_toolsets, _get_platform_tools,
_toolset_configuration_platform, _toolset_has_keys, get_nous_subscription_features,
gui_toolset_label)
from hermes_cli.platforms import platform_label
from toolsets import resolve_toolset
from utils import is_truthy_value
def _read():
with _profile_scope(profile):
config = load_config()
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}
features = get_nous_subscription_features(config)
return config, toolset_rows, enabled_by_platform, features
config, toolset_rows, enabled_by_platform, features = await run_in_threadpool(_read)
result = []
for name, label, desc in toolset_rows:
try:
tools = sorted(set(resolve_toolset(name)))
except Exception:
tools = []
target_platform = _toolset_configuration_platform(name)
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) have no per-platform toolset —
# their switch is their own config section (e.g. stt.enabled).
section = config.get(name)
section = section if isinstance(section, dict) else {}
is_enabled = is_truthy_value(section.get("enabled", True), default=True)
else:
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, features=features), "tools": tools})
return result
@router.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for its configuration platform
(``platform_toolsets.cli`` for most; platform-restricted toolsets target
their own platform) via the same ``_save_platform_tools`` the CLI uses."""
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS, _get_platform_tools, _save_platform_tools,
_toolset_configuration_platform)
_require_known_toolset(name)
target_platform = _toolset_configuration_platform(name)
scope_profile = body.profile or profile
def _run():
with config_write_scope(scope_profile):
config = load_config()
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) toggle their own section's
# ``enabled`` flag — there is no platform_toolsets entry.
_dict_section(config, name)["enabled"] = bool(body.enabled)
save_config(config)
return
enabled = set(
_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, target_platform, enabled)
await asyncio.to_thread(_run)
# Install-on-enable: a provider whose post_setup install predicate is
# UNSATISFIED (cua-driver binary missing, etc.) gets the same background
# install `hermes tools` runs — otherwise the toggle "saves" but the tool
# never appears. Best-effort: a spawn failure never fails the toggle.
post_setup_started: Optional[str] = None
if body.enabled and name not in _CONFIG_ONLY_TOOLSETS:
def _pending_install_key() -> Optional[str]:
from hermes_cli.tools_config import (
TOOL_CATEGORIES, _post_setup_already_installed, _visible_providers)
cat = TOOL_CATEGORIES.get(name)
if not cat:
return None
with _profile_scope(scope_profile):
config = load_config()
keys = [prov.get("post_setup") for prov in _visible_providers(cat, config)]
return next((k for k in keys if k and not _post_setup_already_installed(k)), None)
try:
pending_key = await asyncio.to_thread(_pending_install_key)
if pending_key:
_spawn_hermes_action(
_profile_cli_args(scope_profile) + ["tools", "post-setup", pending_key],
"tools-post-setup")
post_setup_started = pending_key
except Exception:
_log.exception("install-on-enable post-setup spawn failed for %s", name)
return {
"ok": True, "name": name, "platform": target_platform, "enabled": body.enabled,
"post_setup_started": post_setup_started}
@router.get("/api/tools/toolsets/{name}/config")
async def get_toolset_config(name: str, profile: Optional[str] = None):
"""Provider matrix + key status for a toolset's config panel (the CLI picker
rows, each env var annotated ``is_set``); no category -> ``has_category: false``."""
from hermes_cli.tools_config import (
TOOL_CATEGORIES, _is_provider_active, _visible_providers, provider_readiness_status,
web_provider_capabilities)
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
_require_known_toolset(name)
def _read():
with _profile_scope(profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
if cat:
# Entitlement state fetched once for the whole matrix.
features = get_nous_subscription_features(config, force_fresh=True)
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", [])]
# Same active-provider determination as the CLI picker, so the
# GUI highlights the provider actually written to config.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
row = {
"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,
# Server-side readiness: zero-env-var rows are NOT
# automatically ready (logged-out Nous rows, never-run
# post_setup installs).
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active)}
if name == "web" and prov.get("web_backend"):
# web is two capabilities (search/extract); surface each
# row's backend key + capabilities for per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
if name == "tts" and prov.get("tts_provider"):
# Key written to tts.provider; doubles as the config section
# (tts.<key>.*) holding the provider's voice/model settings.
row["tts_provider"] = prov["tts_provider"]
providers.append(row)
payload = {
"name": name, "has_category": cat is not None, "providers": providers,
"active_provider": active_provider}
if name == "web":
# Resolve active backends exactly as the web_search/web_extract
# dispatchers do, so badges reflect what a call would hit now.
try:
from tools.web_tools import _get_extract_backend, _get_search_backend
search_backend = _get_search_backend()
extract_backend = _get_extract_backend()
except Exception:
search_backend = extract_backend = None
payload["active_search_backend"] = search_backend
payload["active_extract_backend"] = extract_backend
return payload
return await asyncio.to_thread(_read)
@router.get("/api/tools/toolsets/{name}/models")
async def get_toolset_models(
name: str, provider: Optional[str] = None, profile: Optional[str] = None):
"""Model catalog for a toolset backend (image/video gen) — the GUI
counterpart of the CLI model picker. ``provider`` names a picker row
(default: the active provider); no catalog -> ``has_models: false``."""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
return _no_models(name)
def _read():
with _profile_scope(profile):
config = load_config()
row = _find_toolset_provider_row(name, config, provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
return None
catalog, default_model = _toolset_model_catalog(name, plugin)
section_cfg = config.get(section)
current = None
if isinstance(section_cfg, dict):
raw = section_cfg.get("model")
if isinstance(raw, str) and raw.strip():
current = raw.strip()
if current not in catalog:
current = default_model if default_model in catalog else None
models = [
{
"id": model_id, "display": meta.get("display", model_id),
"speed": meta.get("speed", ""), "strengths": meta.get("strengths", ""),
"price": meta.get("price", "")}
for model_id, meta in catalog.items()]
return {
"name": name, "has_models": bool(models), "provider": row.get("name"),
"plugin": plugin, "models": models, "current": current, "default": default_model}
return await asyncio.to_thread(_read) or _no_models(name)
@router.put("/api/tools/toolsets/{name}/model")
async def select_toolset_model(
name: str, body: ToolsetModelSelect, profile: Optional[str] = None):
"""Persist a backend model selection (``image_gen.model`` /
``video_gen.model``), validated against the resolved backend's catalog."""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
raise _bad_request(f"Toolset has no model catalog: {name}")
model_id = (body.model or "").strip()
if not model_id:
raise _bad_request("model is required")
def _run():
with config_write_scope(body.profile or profile):
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise _bad_request(f"No model-capable backend is active for {name}")
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise _bad_request(f"Unknown model {model_id!r} for backend {plugin!r}")
_dict_section(config, section)["model"] = model_id
save_config(config)
return plugin
plugin = await asyncio.to_thread(_run)
return {"ok": True, "name": name, "model": model_id, "plugin": plugin}
@router.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None):
"""Persist a provider selection via ``apply_provider_selection`` (shared with
``hermes tools``, so both write identical keys).
``web`` only: ``capability`` ('search' | 'extract') writes
``web.<capability>_backend`` (the override the dispatchers resolve first);
omitted -> legacy ``web.backend``. Managed Nous rows report Portal
entitlement (``needs_nous_auth`` + ``feature``): the GUI has no inline
login, so an unentitled selection would write config and never activate.
"""
from hermes_cli.tools_config import apply_provider_selection, web_provider_capabilities
from hermes_cli.nous_subscription import (
MANAGED_FEATURE_COVERAGE_CATEGORY, get_nous_subscription_features)
_require_known_toolset(name)
if body.capability is not None:
if name != "web":
raise _bad_request("capability selection is only supported for the web toolset")
if body.capability not in ("search", "extract"):
raise _bad_request(
f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')")
def _provider_row(config):
return next(
(p for p in _category_providers(name, config) if p.get("name") == body.provider), None)
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
config = load_config()
if body.capability is not None:
# Per-capability path writes web.<capability>_backend only —
# web.backend is untouched so the other capability keeps
# resolving through the shared fallback chain.
prov = _provider_row(config)
if prov is None:
raise _bad_request(
f"Unknown provider {body.provider!r} for toolset {name!r}")
backend = prov.get("web_backend")
if not backend:
raise _bad_request(f"Provider {body.provider!r} has no web backend key")
if body.capability not in web_provider_capabilities(backend):
raise _bad_request(f"{body.provider} does not support {body.capability}")
_dict_section(config, "web")[f"{body.capability}_backend"] = backend
else:
try:
apply_provider_selection(name, body.provider, config)
except KeyError as exc:
raise _bad_request(str(exc).strip('"'))
save_config(config)
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
if body.capability is not None:
response["capability"] = body.capability
# Entitlement check for managed Nous rows (mirrors the CLI's
# ensure_nous_portal_access gate). Hits the Portal, so it runs AFTER
# releasing the mutation lock — still in the worker thread + scope.
row = _provider_row(config)
managed_feature = (row or {}).get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config, force_fresh=True)
acct = features.account_info
category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
entitled = bool(
acct
and acct.logged_in
and (
acct.tool_gateway_entitled_for(category)
if category
else acct.tool_gateway_entitled))
if not entitled:
response["needs_nous_auth"] = True
response["feature"] = managed_feature
return response
return await asyncio.to_thread(_run)
@router.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys to ``.env`` via ``save_env_value``. Keys are validated
against the union of the category's visible-provider ``env_vars`` so this
can't write arbitrary env vars; a blank value means "leave unchanged"."""
from hermes_cli.config import get_env_value, save_env_value
_require_known_toolset(name)
def _run():
with _profile_scope(body.profile or profile):
config = load_config()
allowed: set[str] = {
e["key"]
for prov in _category_providers(name, config)
for e in prov.get("env_vars", [])}
unknown = [k for k in body.env if k not in allowed]
if unknown:
raise _bad_request(
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 _bad_request(str(exc))
saved.append(key)
else:
skipped.append(key)
status = {k: bool(get_env_value(k)) for k in allowed}
return saved, skipped, status
saved, skipped, status = await asyncio.to_thread(_run)
return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
@router.post("/api/tools/toolsets/{name}/post-setup")
async def run_toolset_post_setup(
name: str, body: ToolsetPostSetup, profile: Optional[str] = None):
"""Spawn ``hermes tools post-setup <key>`` (long-running installs) as a
background action tailed via ``GET /api/actions/tools-post-setup/status``;
``profile`` is threaded so hooks see the drawer's HERMES_HOME."""
from hermes_cli.tools_config import valid_post_setup_keys
_require_known_toolset(name)
if body.key not in valid_post_setup_keys():
raise _bad_request(f"Unknown post-setup key: {body.key}")
result = spawn_profile_action(
body.profile or profile, ["tools", "post-setup", body.key], "tools-post-setup",
log_msg="Failed to spawn tools post-setup", prefix="Failed to run post-setup")
result["key"] = body.key
return result
@router.get("/api/tools/terminal/backends")
async def get_terminal_backends(profile: Optional[str] = None):
"""Terminal backend rows with health probes: ``status`` is ``ready`` /
``needs_setup`` / ``unavailable``; a probe failure is a status, never an
error response."""
def _read():
with _profile_scope(profile):
config = load_config()
terminal_cfg = config.get("terminal")
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
rows = _terminal_backend_rows()
active = str(terminal_cfg.get("backend") or "local").strip().lower()
if active not in {row["name"] for row in rows}:
active = "local"
backends = []
for row in rows:
status, detail = _probe_terminal_backend(row["name"], terminal_cfg)
backends.append({
"name": row["name"], "label": row["label"], "description": row["description"],
"active": row["name"] == active, "status": status, "detail": detail})
return {"active": active, "backends": backends}
return await asyncio.to_thread(_read)
@router.put("/api/tools/terminal/backend")
async def select_terminal_backend(
body: TerminalBackendSelect, profile: Optional[str] = None):
"""Persist ``terminal.backend``. A backend that still needs setup is
allowed — the picker shows guidance instead of blocking, like the CLI."""
backend = (body.backend or "").strip().lower()
valid_names = {row["name"] for row in _terminal_backend_rows()}
if backend not in valid_names:
raise _bad_request(
f"Unknown terminal backend: {body.backend!r}. "
f"Use one of: {', '.join(sorted(valid_names))}")
def _run():
with config_write_scope(body.profile or profile):
config = load_config()
_dict_section(config, "terminal")["backend"] = backend
save_config(config)
await asyncio.to_thread(_run)
return {"ok": True, "backend": backend}
@router.get("/api/tools/computer-use/status")
async def get_computer_use_status(profile: Optional[str] = None):
"""Computer Use readiness for the desktop card (payload shape: see
``tools.computer_use.permissions.computer_use_status``)."""
from tools.computer_use.permissions import computer_use_status
return await scoped_to_thread(profile, computer_use_status)
@router.post("/api/tools/computer-use/permissions/grant")
async def grant_computer_use_permissions(profile: Optional[str] = None):
"""Spawn ``hermes computer-use permissions grant`` (macOS-only: launches
CuaDriver via LaunchServices so the TCC dialog is attributed correctly).
The frontend polls ``GET /api/actions/computer-use-grant/status``."""
if sys.platform != "darwin":
raise _bad_request("Computer Use permission grants are a macOS concept.")
return spawn_profile_action(
profile, ["computer-use", "permissions", "grant"], "computer-use-grant",
log_msg="Failed to spawn computer-use permissions grant",
prefix="Failed to request permissions")
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
import logging # noqa: F401,E402
_PLUGIN_COMPAT_LAZY = {
'LateState': ('hermes_cli.web_deps', 'LateState'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----