diff --git a/hermes_cli/web_deps.py b/hermes_cli/web_deps.py index 4c1ceaa7d4d..e21ccbd36a3 100644 --- a/hermes_cli/web_deps.py +++ b/hermes_cli/web_deps.py @@ -56,6 +56,85 @@ def late_attr(name: str) -> Any: return getattr(_server(), name) +class LateState: + """Live proxy for module-level state owned by ``web_server``. + + Extracted routers can't ``from web_server import _mcp_oauth_flows`` — + that would freeze the object at import time (breaking tests that mutate + or replace it on ``web_server``) and be a circular import besides. Some + of the state is also defined *after* the router's ``include_router`` + point in web_server's body, so even a late module-import wouldn't see it + yet. This proxy forwards every operation the extracted handlers actually + perform — attribute access, item get/set/del, iteration, membership, + ``len``/truthiness, ``with``-blocks (locks), and rich comparisons + (numeric limits) — to ``web_server.`` resolved at operation time, + so mutating or monkeypatching the attribute on ``web_server`` stays + authoritative. + """ + + __slots__ = ("_name",) + + def __init__(self, name: str) -> None: + object.__setattr__(self, "_name", name) + + def _target(self) -> Any: + return getattr(_server(), object.__getattribute__(self, "_name")) + + def __getattr__(self, attr: str) -> Any: + return getattr(self._target(), attr) + + def __getitem__(self, key: Any) -> Any: + return self._target()[key] + + def __setitem__(self, key: Any, value: Any) -> None: + self._target()[key] = value + + def __delitem__(self, key: Any) -> None: + del self._target()[key] + + def __contains__(self, item: Any) -> bool: + return item in self._target() + + def __iter__(self): + return iter(self._target()) + + def __len__(self) -> int: + return len(self._target()) + + def __bool__(self) -> bool: + return bool(self._target()) + + def __enter__(self): + return self._target().__enter__() + + def __exit__(self, *exc): + return self._target().__exit__(*exc) + + def __eq__(self, other: Any) -> bool: + return self._target() == other + + def __ne__(self, other: Any) -> bool: + return self._target() != other + + def __lt__(self, other: Any) -> bool: + return self._target() < other + + def __le__(self, other: Any) -> bool: + return self._target() <= other + + def __gt__(self, other: Any) -> bool: + return self._target() > other + + def __ge__(self, other: Any) -> bool: + return self._target() >= other + + def __hash__(self) -> int: + return hash(self._target()) + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return f" {self._target()!r}>" + + # --- Named accessors for the shared server state (call-time reads) --------- diff --git a/hermes_cli/web_routers/mcp.py b/hermes_cli/web_routers/mcp.py new file mode 100644 index 00000000000..ef900d8e39d --- /dev/null +++ b/hermes_cli/web_routers/mcp.py @@ -0,0 +1,478 @@ +"""MCP dashboard routes (extracted verbatim from web_server.py). + +Handler bodies are byte-identical. The OAuth flow registry +(``_mcp_oauth_flows`` + lock + pending cap) and the worker/helpers stay in +web_server - reached via the late-binding seam in :mod:`hermes_cli.web_deps` +(``late`` for callables, ``LateState`` for the mutable registry/lock/limit) so +tests that mutate ``web_server._mcp_oauth_flows`` or +``monkeypatch.setattr(web_server, "_run_dashboard_mcp_oauth", ...)`` keep +working unchanged. +""" + +import asyncio # noqa: F401 — used by handlers +import logging +import secrets # noqa: F401 +import threading # noqa: F401 +from typing import Any, Dict, Optional # noqa: F401 + +from fastapi import APIRouter, HTTPException, Request # noqa: F401 +from fastapi.responses import HTMLResponse # noqa: F401 + +from hermes_cli.web_deps import late, LateState +from hermes_cli.web_models import ( + MCPCatalogInstall, + MCPEnabledToggle, + MCPServerCreate, + MCPServersReplace, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_config_profile_scope = late("_config_profile_scope") +_gc_mcp_oauth_flows = late("_gc_mcp_oauth_flows") +_mcp_install_action_name = late("_mcp_install_action_name") +_mcp_oauth_callback_url = late("_mcp_oauth_callback_url") +_mcp_server_summary = late("_mcp_server_summary") +_normalize_mcp_server_create = late("_normalize_mcp_server_create") +_profile_cli_args = late("_profile_cli_args") +_profile_scope = late("_profile_scope") +_require_token = late("_require_token") +_run_dashboard_mcp_oauth = late("_run_dashboard_mcp_oauth") +_spawn_hermes_action = late("_spawn_hermes_action") +load_config = late("load_config") +save_config = late("save_config") +save_env_value = late("save_env_value") + +# Live proxies for web_server-owned module state (mutations/monkeypatches +# on web_server remain authoritative; resolved at operation time). +_mcp_oauth_flows = LateState("_mcp_oauth_flows") +_mcp_oauth_flows_lock = LateState("_mcp_oauth_flows_lock") +_MAX_PENDING_MCP_OAUTH_FLOWS = LateState("_MAX_PENDING_MCP_OAUTH_FLOWS") + + +@router.get("/api/mcp/servers") +async def list_mcp_servers(profile: Optional[str] = None): + from hermes_cli.mcp_config import _get_mcp_servers + + with _profile_scope(profile): + servers = _get_mcp_servers() + return { + "servers": [ + _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items()) + ] + } + + +@router.post("/api/mcp/servers") +async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None): + from hermes_cli.mcp_config import ( + _get_mcp_servers, + _save_bearer_auth_token, + _save_mcp_server, + ) + + try: + name, server_config, bearer_token = _normalize_mcp_server_create(body) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + with _profile_scope(body.profile or profile): + existing = _get_mcp_servers() + if name in existing: + raise HTTPException(status_code=409, detail=f"Server '{name}' already exists") + + try: + with _profile_scope(body.profile or profile): + if bearer_token is not None: + server_config["headers"] = _save_bearer_auth_token(name, bearer_token) + if not _save_mcp_server(name, server_config): + raise HTTPException( + status_code=400, + detail=f"Server '{name}' rejected: suspicious command/args configuration", + ) + except HTTPException: + raise + except Exception as exc: + _log.exception("POST /api/mcp/servers failed") + raise HTTPException(status_code=400, detail=str(exc)) from exc + + return _mcp_server_summary(name, server_config) + + +@router.put("/api/mcp/servers") +async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None): + """Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save). + + The generic ``/api/config`` endpoint deep-merges maps, so it can never + delete a server key, drop an ``enabled: false`` flag, or remove a nested + field — edits looked saved but the stale entry survived on disk. This + endpoint sets the whole map so removals actually persist. Storage stays + the config.yaml ``mcp_servers`` key the CLI/TUI already read. + """ + from hermes_cli.mcp_config import _replace_mcp_servers + + with _profile_scope(body.profile or profile): + ok, issues = _replace_mcp_servers(body.servers) + if not ok: + raise HTTPException(status_code=400, detail="; ".join(issues)) + return {"ok": True} + + +@router.delete("/api/mcp/servers/{name}") +async def remove_mcp_server(name: str, profile: Optional[str] = None): + from hermes_cli.mcp_config import _remove_mcp_server + + with _profile_scope(profile): + removed = _remove_mcp_server(name) + if not removed: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + return {"ok": True} + + +@router.post("/api/mcp/servers/{name}/test") +async def test_mcp_server(name: str, profile: Optional[str] = None): + """Connect to the server, list its tools, disconnect. Returns tool list.""" + from hermes_cli.mcp_config import ( + _get_mcp_servers, + _oauth_tokens_present, + _probe_single_server, + ) + + with _profile_scope(profile): + servers = _get_mcp_servers() + if name not in servers: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + + details: Dict[str, Any] = {} + # An `auth: oauth` server that serves tools/list anonymously would probe OK + # with no token — a false green. Require a token on disk for it, matching the + # /auth verification (some providers don't enforce auth on tools/list). + needs_oauth_token = servers[name].get("auth") == "oauth" + + def _probe_scoped(): + # Home-only scope (contextvar), NOT _profile_scope. A probe blocks for + # as long as the server takes to spawn/connect — a stdio `npx` cold + # start is many seconds — and _profile_scope holds a process-global + # skills lock for its ENTIRE body. Holding that across the probe + # serialized every other endpoint (config/skills/toolsets all take the + # same lock), so a slow server made unrelated requests time out at 15s. + # The probe touches no skills globals; it only needs the HERMES_HOME + # override for .env interpolation + OAuth token resolution, which the + # contextvar provides (copied into this to_thread worker; and + # _run_on_mcp_loop re-wraps it onto the MCP event-loop thread). + with _config_profile_scope(profile): + tools = _probe_single_server(name, servers[name], details=details) + token_present = _oauth_tokens_present(name) if needs_oauth_token else True + return tools, token_present + + try: + # Probe blocks on a dedicated MCP event loop — run in a thread so the + # FastAPI event loop is never blocked. + tools, token_present = await asyncio.to_thread(_probe_scoped) + except Exception as exc: + return { + "ok": False, + "error": str(exc), + "tools": [], + } + if not token_present: + return { + "ok": False, + "error": "OAuth authentication required — no token found.", + "tools": [], + } + return { + "ok": True, + "tools": [{"name": t, "description": d} for t, d in tools], + "prompts": details.get("prompts", 0), + "resources": details.get("resources", 0), + } + + +@router.post("/api/mcp/servers/{name}/auth") +async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = None): + """Start MCP OAuth and hand the authorization URL to the dashboard browser.""" + from hermes_cli.mcp_config import _get_mcp_servers + from tools.mcp_dashboard_oauth import DashboardOAuthFlow + + _require_token(request) + _gc_mcp_oauth_flows() + from hermes_constants import get_hermes_home + + process_home = str(get_hermes_home().expanduser().resolve(strict=False)) + with _profile_scope(profile): + servers = _get_mcp_servers() + flow_home = str(get_hermes_home().expanduser().resolve(strict=False)) + if name not in servers: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + cfg = dict(servers[name]) + if not cfg.get("url"): + raise HTTPException(status_code=400, detail="stdio servers authenticate via env keys, not OAuth") + if cfg.get("headers") and cfg.get("auth") != "oauth": + raise HTTPException(status_code=400, detail="This server uses header/API-key auth, not OAuth") + cfg["auth"] = "oauth" + + flow_id = secrets.token_urlsafe(24) + flow = DashboardOAuthFlow( + flow_id=flow_id, + server_name=name, + profile=profile, + hermes_home=flow_home, + redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri") + or _mcp_oauth_callback_url(request, name), + reconnect_live=flow_home == process_home, + ) + with _mcp_oauth_flows_lock: + pending = sum( + not flow.worker_done + for flow in _mcp_oauth_flows.values() + ) + if pending >= _MAX_PENDING_MCP_OAUTH_FLOWS: + raise HTTPException( + status_code=429, + detail="Too many MCP OAuth flows are already in progress", + ) + if any( + flow.server_name == name + and flow.hermes_home == flow_home + and not flow.worker_done + for flow in _mcp_oauth_flows.values() + ): + raise HTTPException( + status_code=409, + detail=f"MCP OAuth for '{name}' is already in progress", + ) + _mcp_oauth_flows[flow_id] = flow + threading.Thread( + target=_run_dashboard_mcp_oauth, + args=(flow, cfg), + daemon=True, + name=f"mcp-oauth-{name}", + ).start() + try: + await flow.wait_for_authorization_url(timeout=30) + except Exception as exc: + flow.mark_error(str(exc)) + return flow.snapshot() + + +@router.get("/api/mcp/oauth/flows/{flow_id}") +async def mcp_oauth_flow_status(flow_id: str, request: Request): + _require_token(request) + _gc_mcp_oauth_flows() + flow = _mcp_oauth_flows.get(flow_id) + if flow is None: + raise HTTPException(status_code=404, detail="OAuth flow not found or expired") + snapshot = flow.snapshot() + snapshot["tools"] = flow.tools + return snapshot + + +@router.get("/api/mcp/oauth/callback/{server_name:path}") +async def mcp_oauth_callback( + server_name: str, + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, +): + _gc_mcp_oauth_flows() + with _mcp_oauth_flows_lock: + candidates = [ + flow + for flow in _mcp_oauth_flows.values() + if flow.server_name == server_name + and flow.status == "authorization_required" + ] + flow = next( + ( + candidate + for candidate in candidates + if candidate.expected_state is not None + and state is not None + and secrets.compare_digest(candidate.expected_state, state) + ), + None, + ) + if flow is None: + return HTMLResponse("

OAuth flow expired

Return to Hermes and try again.

", status_code=404) + try: + flow.deliver_callback(code=code, state=state, error=error) + except ValueError as exc: + reason = str(exc) + status_code = 409 if "already received" in reason else 400 + return HTMLResponse( + "

OAuth callback rejected

" + "

The callback was invalid or already used.

", + status_code=status_code, + ) + if error: + return HTMLResponse("

Authorization failed

Return to Hermes for details.

", status_code=400) + return HTMLResponse("

Authorization received

You can close this tab and return to Hermes.

") + + +@router.put("/api/mcp/servers/{name}/enabled") +async def set_mcp_server_enabled( + name: str, body: MCPEnabledToggle, profile: Optional[str] = None +): + """Enable or disable an MCP server (takes effect on next session/gateway). + + Toggles the ``enabled`` key on the server's config.yaml entry — the same + flag the agent reads at startup. Disabled servers stay in config so they + can be re-enabled without re-entering their settings. + """ + with _profile_scope(body.profile or profile): + cfg = load_config() + servers = cfg.get("mcp_servers") + if not isinstance(servers, dict) or name not in servers: + raise HTTPException(status_code=404, detail=f"Server '{name}' not found") + if not isinstance(servers[name], dict): + raise HTTPException(status_code=400, detail="Malformed server config") + servers[name]["enabled"] = bool(body.enabled) + save_config(cfg) + return {"ok": True, "name": name, "enabled": bool(body.enabled)} + + +@router.get("/api/mcp/catalog") +async def list_mcp_catalog(profile: Optional[str] = None): + """Browse the Nous-approved MCP catalog (the optional-mcps/ manifests). + + Each entry reports whether it's already installed and enabled so the UI + can show install / enabled state inline. This is the same catalog + `hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes + the installed/enabled annotations (the catalog itself is repo-shipped + and identical for every profile). + """ + try: + from hermes_cli import mcp_catalog + except Exception as exc: + _log.exception("mcp_catalog import failed") + raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}") + + entries = [] + try: + with _profile_scope(profile): + catalog_entries = list(mcp_catalog.list_catalog()) + installed_state = { + e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name)) + for e in catalog_entries + } + for entry in catalog_entries: + auth = entry.auth + transport = entry.transport + install = entry.install + entries.append({ + "name": entry.name, + "description": entry.description, + "source": entry.source, + "transport": transport.type, + "auth_type": getattr(auth, "type", "none"), + # Env vars the user must supply (names + prompts only, never values). + "required_env": [ + {"name": e.name, "prompt": e.prompt, "required": e.required} + for e in getattr(auth, "env", []) or [] + ], + # Transport details so the UI can show exactly what connects/runs. + # The trust model (docs: user-guide/features/mcp) tells users to + # inspect command/args/url and the install bootstrap before + # installing — surface them rather than hiding them in the repo. + "command": transport.command, + "args": list(transport.args or []), + "url": transport.url, + # Git bootstrap (present only for entries that clone + build). + "install_url": install.url if install else None, + "install_ref": install.ref if install else None, + "bootstrap": list(install.bootstrap) if install else [], + # Default tool pre-selection hint and post-install guidance. + "default_enabled": list(entry.tools.default_enabled) + if entry.tools.default_enabled is not None + else None, + "post_install": entry.post_install or "", + "needs_install": entry.install is not None, + "installed": installed_state.get(entry.name, (False, False))[0], + "enabled": installed_state.get(entry.name, (False, False))[1], + }) + except HTTPException: + # Unknown/invalid profile → 404, not a silently-empty catalog. + raise + except Exception: + _log.exception("list_mcp_catalog failed") + + diagnostics = [] + try: + diagnostics = [ + {"name": n, "kind": k, "message": m} + for (n, k, m) in mcp_catalog.catalog_diagnostics() + ] + except Exception: + pass + + return {"entries": entries, "diagnostics": diagnostics} + + +@router.post("/api/mcp/catalog/install") +async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None): + """Install a catalog MCP into config.yaml. + + For HTTP/stdio entries with required env vars, those are written to .env + via the standard env path so the agent can read them at session start. + Entries that need a git bootstrap (``needs_install``) are installed via + the CLI action path because the clone can take time. + """ + from hermes_cli import mcp_catalog + + name = (body.name or "").strip() + entry = mcp_catalog.get_entry(name) + if entry is None: + raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'") + + # Persist any supplied env vars first (catalog entries declare which names + # they need; we only write the ones the user provided). + effective_profile = body.profile or profile + if body.env: + with _profile_scope(effective_profile): + for k, v in body.env.items(): + if v: + save_env_value(k, v) + + # Git-bootstrap entries can take a while to clone — run via the background + # action path so the request returns immediately and the UI can tail logs. + # The -p subprocess rebinds HERMES_HOME-derived paths in the child. + if entry.install is not None: + # Unique per-entry action name: a shared "mcp-install" would let a + # re-click (or a second entry) overwrite the tracked process/log while + # the first clone is still running. + action = _mcp_install_action_name(name) + try: + _spawn_hermes_action( + _profile_cli_args(effective_profile) + ["mcp", "install", name], + action, + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=500, detail=f"Install failed: {exc}") + return {"ok": True, "name": name, "background": True, "action": action} + + # No git step — install synchronously via the catalog API. install_entry + # routes through load_config/save_config + save_env_value, all call-time + # resolvers, so the context override scopes it. Wrap the to_thread body + # in the scope INSIDE the thread (contextvars don't propagate into + # to_thread the other way around — asyncio.to_thread copies context, so + # setting it here works; keep it explicit for clarity). + def _install_scoped(): + with _profile_scope(effective_profile): + mcp_catalog.install_entry(entry, enable=body.enable) + + try: + await asyncio.to_thread(_install_scoped) + except HTTPException: + raise + except Exception as exc: + _log.exception("install_mcp_catalog_entry failed") + raise HTTPException(status_code=400, detail=str(exc)) + return {"ok": True, "name": name, "background": False} diff --git a/hermes_cli/web_routers/sessions.py b/hermes_cli/web_routers/sessions.py new file mode 100644 index 00000000000..e8cb87a6e7e --- /dev/null +++ b/hermes_cli/web_routers/sessions.py @@ -0,0 +1,709 @@ +"""Session dashboard routes (extracted verbatim from web_server.py). + +Three routers because the original registration points are far apart and +global route order matters: ``list_router`` (GET /api/sessions) was registered +before the profiles ``sessions_router`` include, ``search_router`` +(GET /api/sessions/search) right after it, and ``manage_router`` (the +mutation/detail endpoints) thousands of lines later - each is mounted at its +original registration point so the app's route table is byte-identical. + +Handler bodies are byte-identical; web_server-owned helpers are reached via +the late-binding seam in :mod:`hermes_cli.web_deps` so tests that +``monkeypatch.setattr(web_server, "_helper", ...)`` keep working. +""" + +import asyncio # noqa: F401 — used by handlers +import logging +import time # noqa: F401 +from typing import Any, Dict, List, Optional # noqa: F401 + +from fastapi import APIRouter, HTTPException, Request # noqa: F401 + +from hermes_cli.web_deps import late +from hermes_cli.web_models import ( + BulkDeleteSessions, + SessionImport, + SessionPrune, + SessionRename, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +list_router = APIRouter() +search_router = APIRouter() +manage_router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_cron_default_profile = late("_cron_default_profile") +_cron_profile_home = late("_cron_profile_home") +_import_sessions_for_profile = late("_import_sessions_for_profile") +_maybe_auto_archive_for_profile = late("_maybe_auto_archive_for_profile") +_open_session_db_for_profile = late("_open_session_db_for_profile") +_prune_sessions = late("_prune_sessions") +_read_session_import_body = late("_read_session_import_body") +_session_latest_descendant = late("_session_latest_descendant") +_strip_session_list_rows = late("_strip_session_list_rows") + + +@list_router.get("/api/sessions") +def get_sessions( + limit: int = 20, + offset: int = 0, + min_messages: int = 0, + archived: str = "exclude", + order: str = "created", + source: str = None, + sources: str = None, + exclude_sources: str = None, + cwd_prefix: str = None, + full: bool = False, + profile: Optional[str] = None, +): + """List sessions. + + ``archived`` controls how soft-archived sessions are treated: + ``exclude`` (default) hides them, ``only`` returns just the archived ones + (used by the desktop "Archived sessions" settings panel), and ``include`` + returns both. + + ``order`` controls pagination order: ``created`` (default, by original + start time) or ``recent`` (by latest activity across the compression + chain). ``recent`` keeps a long-running conversation on the first page + after it auto-compresses into a fresh continuation id. + + Rows omit ``system_prompt``/``model_config`` (the payload-dominating + fields no list UI reads) unless ``full=1`` is passed. + """ + if archived not in ("exclude", "only", "include"): + raise HTTPException( + status_code=400, + detail="archived must be one of: exclude, only, include", + ) + if order not in ("created", "recent"): + raise HTTPException( + status_code=400, + detail="order must be one of: created, recent", + ) + profile_name: Optional[str] = None + if profile: + profile_name, _ = _cron_profile_home(profile) + try: + db = _open_session_db_for_profile(profile) + try: + # Opportunistic, config-gated, double-throttled stale-session + # sweep — the only auto_archive hook that fires for Desktop's + # `hermes serve` backend. No-op when disabled or run recently. + _maybe_auto_archive_for_profile(db, profile) + min_message_count = max(0, min_messages) + archived_only = archived == "only" + include_archived = archived == "include" + # Optional source scoping: ``source`` includes a single class, + # ``sources`` includes any of several comma-separated classes, and + # ``exclude_sources`` (comma-separated) drops classes. The desktop + # uses these to split recents (exclude=cron) from the cron-jobs + # section (source=cron) into two independent lists. + source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] + exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] + sessions = db.list_sessions_rich( + source=source or None, + sources=source_list or None, + exclude_sources=exclude_list or None, + cwd_prefix=(cwd_prefix or None), + limit=limit, + offset=offset, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + order_by_last_active=order == "recent", + # SQL-level projection: when the caller didn't ask for full + # rows, skip the system_prompt blob inside SQLite too (pairs + # with the API-level _strip_session_list_rows below). + compact_rows=not full, + include_pinned=True, + ) + total = db.session_count( + source=source or None, + sources=source_list or None, + cwd_prefix=(cwd_prefix or None), + exclude_sources=exclude_list or None, + min_message_count=min_message_count, + include_archived=include_archived, + archived_only=archived_only, + exclude_children=True, + ) + now = time.time() + # Same ownership contract as get_session_detail: rows are stamped + # with the serving profile even when the request wasn't explicitly + # scoped, so default-profile rows never circulate unowned. + row_profile = profile_name or _cron_default_profile() + for s in sessions: + s["is_active"] = ( + s.get("ended_at") is None + and (now - s.get("last_active", s.get("started_at", 0))) < 300 + ) + s["profile"] = row_profile + s["is_default_profile"] = row_profile == "default" + # SQLite stores the flag as 0/1; expose a real JSON boolean. + s["archived"] = bool(s.get("archived")) + s["pinned"] = bool(s.get("pinned")) + if not full: + _strip_session_list_rows(sessions) + return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} + finally: + db.close() + except HTTPException: + raise + except Exception: + _log.exception("GET /api/sessions failed") + raise HTTPException(status_code=500, detail="Internal server error") + + +@search_router.get("/api/sessions/search") +async def search_sessions( + q: str = "", + limit: int = 20, + profile: Optional[str] = None, + source: str = None, + sources: str = None, + exclude_sources: str = None, +): + """Search sessions by ID plus full-text message content using FTS5. + + Direct session-id matches are surfaced first, then FTS message-content + matches. Results are deduped by compression lineage, not by raw + ``session_id``. Auto-compression rotates a conversation onto a fresh + session id (and leaves the old segment's messages in the FTS index), so one + logical chat can own many ``sessions`` rows that all match the same query. + Branches also use ``parent_session_id``, but they are real alternate + conversations; don't collapse branch-specific hits back into the parent. + """ + if not q or not q.strip(): + return {"results": []} + try: + db = _open_session_db_for_profile(profile) + try: + safe_limit = max(1, min(int(limit or 20), 100)) + source_filter = source or None + source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] + include_sources = [source_filter] if source_filter else (source_list or None) + exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] + now = time.time() + + # Walk parent_session_id to the compression root, memoized so a + # chain of compression segments only costs one walk. We deliberately + # stop at branch/delegate edges: those sessions may diverge from the + # parent and should remain searchable on their own. + root_cache: dict = {} + + def compression_root(session_id: str) -> str: + if not session_id: + return session_id + if session_id in root_cache: + return root_cache[session_id] + chain = [] + cur = session_id + visited = set() + root = session_id + while cur and cur not in visited: + visited.add(cur) + chain.append(cur) + if cur in root_cache: + root = root_cache[cur] + break + try: + s = db.get_session(cur) + except Exception: + s = None + if not s: + root = cur + break + parent = s.get("parent_session_id") if isinstance(s, dict) else None + if not parent: + root = cur + break + try: + parent_session = db.get_session(parent) + except Exception: + parent_session = None + if not parent_session: + root = cur + break + parent_ended_at = parent_session.get("ended_at") + started_at = s.get("started_at") + is_compression_edge = ( + parent_session.get("end_reason") == "compression" + and parent_ended_at is not None + and started_at is not None + and started_at >= parent_ended_at + ) + if not is_compression_edge: + root = cur + break + cur = parent + for node in chain: + root_cache[node] = root + return root + + tip_cache: dict = {} + + def lineage_tip(root_id: str) -> str: + if root_id in tip_cache: + return tip_cache[root_id] + tip = root_id + try: + resolved = db.get_compression_tip(root_id) + if resolved: + tip = resolved + except Exception: + pass + tip_cache[root_id] = tip + return tip + + # Both ID matches and content matches share one keyspace, keyed by + # compression lineage root, so an id-hit and a content-hit on the + # same logical conversation collapse to a single result. The first + # hit for a lineage wins; ID matches run first and take priority. + seen: dict = {} + + def add_lineage_result(raw_sid: str, payload: dict) -> None: + if not raw_sid: + return + root = compression_root(raw_sid) + if root in seen or len(seen) >= safe_limit: + return + payload = dict(payload) + sid = lineage_tip(root) + payload["session_id"] = sid + payload["lineage_root"] = root + try: + row = db.get_session_rich_row(sid) + except Exception: + row = None + if row: + payload.update( + { + "id": row.get("id") or sid, + "source": row.get("source"), + "model": row.get("model"), + "title": row.get("title"), + "started_at": row.get("started_at"), + "ended_at": row.get("ended_at"), + "last_active": row.get("last_active") or row.get("started_at"), + "is_active": ( + row.get("ended_at") is None + and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300 + ), + "message_count": row.get("message_count") or 0, + "tool_call_count": row.get("tool_call_count") or 0, + "input_tokens": row.get("input_tokens") or 0, + "output_tokens": row.get("output_tokens") or 0, + "preview": row.get("preview"), + "parent_session_id": row.get("parent_session_id"), + "archived": bool(row.get("archived")), + } + ) + else: + payload["id"] = sid + seen[root] = payload + + # Direct ID matches first: users often paste a session id from CLI, + # logs, or another Hermes surface. FTS can't find those unless the + # id happens to appear in message text. search_sessions_by_id is + # SQL-bounded, so this stays cheap even with thousands of sessions. + for row in db.search_sessions_by_id( + q, + limit=safe_limit, + include_archived=True, + source=source_filter, + sources=source_list or None, + exclude_sources=exclude_list or None, + ): + sid = row.get("id") + preview = (row.get("preview") or "").strip() + snippet = preview or f"Session ID: {sid}" + add_lineage_result( + sid, + { + "snippet": snippet, + "role": None, + "source": row.get("source"), + "model": row.get("model"), + "session_started": row.get("started_at"), + }, + ) + + # Auto-add prefix wildcards so partial words match + # e.g. "nimb" → "nimb*" matches "nimby" + # Preserve quoted phrases and existing wildcards as-is + import re + terms = [] + for token in re.findall(r'"[^"]*"|\S+', q.strip()): + if token.startswith('"') or token.endswith("*"): + terms.append(token) + else: + terms.append(token + "*") + prefix_query = " ".join(terms) + # Over-fetch so lineage dedup can still surface `limit` distinct + # conversations even when several hits collapse onto one root. + fetch_limit = max(safe_limit * 5, 50) + matches = db.search_messages( + query=prefix_query, + source_filter=include_sources, + exclude_sources=exclude_list or None, + limit=fetch_limit, + ) + + for m in matches: + if len(seen) >= safe_limit: + break + add_lineage_result( + m["session_id"], + { + "snippet": m.get("snippet", ""), + "role": m.get("role"), + "source": m.get("source"), + "model": m.get("model"), + "session_started": m.get("session_started"), + }, + ) + return {"results": list(seen.values())} + finally: + db.close() + except HTTPException: + raise + except Exception: + _log.exception("GET /api/sessions/search failed") + raise HTTPException(status_code=500, detail="Search failed") + + +@manage_router.post("/api/sessions/bulk-delete") +async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): + """Delete every session in ``body.ids`` in a single DB transaction. + + Backs the dashboard's bulk-select-and-delete flow on the sessions + page. POST (not DELETE) because most HTTP clients refuse to send a + request body on DELETE and a body is the natural shape for a list + of IDs — Starlette accepts both, but POSTing a list keeps proxies, + curl, and the browser ``fetch`` API consistent. + + Per-row contract matches :meth:`SessionDB.delete_sessions`: + + * Unknown IDs are silently skipped (the response ``deleted`` count + reflects what really happened, not the input length). This is + deliberate — UI selection state can race against another tab's + delete, and we'd rather succeed-on-the-rest than fail-the-whole- + batch. + * Children of every deleted parent are orphaned, not cascade- + deleted. + * Active and archived sessions ARE deleted when explicitly + selected — unlike ``DELETE /api/sessions/empty``, the user + hand-picked the rows so we trust the selection. + * Like the other session-delete endpoints, this does NOT pass a + ``sessions_dir`` through; on-disk transcript / request-dump + cleanup runs at the CLI/agent layer on the next prune pass. + + The response carries the actual deleted count, so the dashboard + can surface it in a toast. The IDs that were removed are not + echoed back because the client already knows what it asked to + delete (unknown IDs are silently skipped — see contract above) + and can prune its in-memory list directly from the request. + """ + # Enforce a hard cap so a runaway/typo'd selection can't lock the + # DB writer for an extended window. The dashboard pages 20 rows + # at a time; 500 covers a "select all on every page in a + # reasonable scrollback" worst case without opening the door to + # multi-thousand-row transactions. + if len(body.ids) > 500: + raise HTTPException( + status_code=400, + detail="ids must contain at most 500 entries", + ) + def _delete() -> int: + db = _open_session_db_for_profile(body.profile) + try: + return db.delete_sessions(body.ids) + finally: + db.close() + + deleted = await asyncio.to_thread(_delete) + return {"ok": True, "deleted": deleted} + + +@manage_router.post("/api/sessions/import") +async def import_sessions_endpoint(request: Request): + """Import one or more sessions exported from the dashboard or CLI. + + This is intentionally separate from ``/api/ops/import``: that endpoint + restores a whole Hermes backup archive, while this endpoint is scoped to + session rows/messages and is safe to use from the Sessions page. + """ + try: + raw_body = await _read_session_import_body(request) + body = SessionImport.model_validate_json(raw_body) + except HTTPException: + raise + except ValueError as exc: + raise HTTPException(status_code=400, detail="Invalid session import payload") from exc + + try: + result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + if not result.get("ok", False): + raise HTTPException(status_code=400, detail=result) + return result + + +@manage_router.get("/api/sessions/empty/count") +async def count_empty_sessions_endpoint(profile: Optional[str] = None): + """Return the number of empty, ended, non-archived sessions. + + Drives the dashboard's "Delete empty (N)" button — when N is 0 the + UI hides the affordance so users aren't presented with a button + that does nothing. Cheap, single-COUNT query. + """ + def _count() -> int: + db = _open_session_db_for_profile(profile) + try: + return db.count_empty_sessions() + finally: + db.close() + + return {"count": await asyncio.to_thread(_count)} + + +@manage_router.delete("/api/sessions/empty") +async def delete_empty_sessions_endpoint(profile: Optional[str] = None): + """Delete every empty (``message_count == 0``), ended, + non-archived session in a single transaction. + + Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`: + + * Active sessions are skipped (``ended_at IS NULL``) so a live + agent isn't yanked mid-handshake. + * Archived sessions are skipped — the user explicitly chose to + keep those rows. + * Children of deleted parents are orphaned, not cascade-deleted. + + Like the single-session ``DELETE /api/sessions/{id}`` endpoint + below, this doesn't pass a ``sessions_dir`` through — the on-disk + transcript / request-dump cleanup is wired at the CLI/agent layer + but the web server historically leaves file cleanup to the next + prune-on-startup pass. Matching that pre-existing trade-off keeps + the two delete endpoints' DB-vs-disk behaviour consistent. + """ + def _delete() -> int: + db = _open_session_db_for_profile(profile) + try: + return db.delete_empty_sessions() + finally: + db.close() + + deleted = await asyncio.to_thread(_delete) + return {"ok": True, "deleted": deleted} + + +@manage_router.get("/api/sessions/stats") +async def get_session_stats(profile: Optional[str] = None): + """Session-store statistics for the Sessions page (mirrors `hermes sessions stats`). + + Registered before ``/api/sessions/{session_id}`` so the literal ``stats`` + path isn't captured as a session id by the parameterized route. + """ + db = _open_session_db_for_profile(profile) + try: + total = db.session_count(include_archived=True) + active_store = db.session_count(include_archived=False) + archived = db.session_count(archived_only=True) + messages = db.message_count() + by_source: Dict[str, int] = {} + try: + by_source = db.session_count_by_source( + include_archived=True, + exclude_children=True, + ) + except Exception: + pass + return { + "total": total, + "active_store": active_store, + "archived": archived, + "messages": messages, + "by_source": by_source, + } + finally: + db.close() + + +@manage_router.get("/api/sessions/{session_id}") +async def get_session_detail(session_id: str, profile: Optional[str] = None): + db = _open_session_db_for_profile(profile) + try: + sid = db.resolve_session_id(session_id) + session = db.get_session(sid) if sid else None + if not session: + raise HTTPException(status_code=404, detail="Session not found") + # Always stamp the owning profile — the serving profile is known even + # when the request carries no ``?profile=`` (it's this process's own + # profile). Stamping only on explicit ``?profile=`` left rows for the + # default/primary profile systematically unowned, so multi-profile + # clients resolved them to whichever gateway happened to be active + # (cross-profile open asymmetry, #67603 family). + session["profile"] = ( + _cron_profile_home(profile)[0] if profile else _cron_default_profile() + ) + session["is_default_profile"] = session["profile"] == "default" + return session + finally: + db.close() + + +@manage_router.get("/api/sessions/{session_id}/latest-descendant") +async def get_session_latest_descendant( + session_id: str, + profile: Optional[str] = None, +): + def _lookup(): + db = _open_session_db_for_profile(profile) + try: + return _session_latest_descendant(session_id, db) + finally: + db.close() + + latest, path = await asyncio.to_thread(_lookup) + if not latest: + raise HTTPException(status_code=404, detail="Session not found") + return { + "requested_session_id": path[0] if path else session_id, + "session_id": latest, + "path": path, + "changed": bool(path and latest != path[0]), + } + + +@manage_router.get("/api/sessions/{session_id}/messages") +async def get_session_messages( + session_id: str, + profile: Optional[str] = None, + limit: Optional[int] = None, + offset: int = 0, +): + def _read(): + db = _open_session_db_for_profile(profile) + try: + sid = db.resolve_session_id(session_id) + if not sid: + return None + sid = db.resolve_resume_session_id(sid) + # Clamp limit to prevent abuse (max 500 per page) + _limit = min(limit, 500) if limit is not None else None + return sid, _limit, db.get_messages(sid, limit=_limit, offset=offset) + finally: + db.close() + + result = await asyncio.to_thread(_read) + if result is None: + raise HTTPException(status_code=404, detail="Session not found") + sid, _limit, messages = result + return { + "session_id": sid, + "messages": messages, + "pagination": { + "limit": _limit, + "offset": offset, + "returned": len(messages), + }, + } + + +@manage_router.delete("/api/sessions/{session_id}") +async def delete_session_endpoint(session_id: str, profile: Optional[str] = None): + # ``profile`` deletes a session belonging to another (local) profile by + # opening its state.db directly. Remote profiles never reach here — the + # desktop routes their DELETE to the remote backend. Omit for current/default. + def _delete(): + db = _open_session_db_for_profile(profile) + try: + # Resolve exact ids / unique prefixes like every other session endpoint + # (detail, messages, rename, export all do). A session that no longer + # exists is an idempotent success: DELETE's contract is "ensure it's + # gone", and the desktop optimistically removes the row then RESTORES it + # on any error — so a 404 on an already-absent row resurrected a ghost + # row and surfaced "session not found". /goal + auto-compression churn + # leaves transient empty rows (reaped by empty-session hygiene) that + # race the sidebar snapshot, which is exactly when this fired. Mirrors + # the bulk-delete endpoint, which already treats ghost ids as success. + sid = db.resolve_session_id(session_id) + if not sid: + return {"ok": True, "already_absent": True} + db.delete_session(sid) + return {"ok": True} + finally: + db.close() + + return await asyncio.to_thread(_delete) + + +@manage_router.patch("/api/sessions/{session_id}") +async def rename_session_endpoint(session_id: str, body: SessionRename): + """Update a session: rename, archive, and/or pin it. + + ``title`` renames (empty/null clears the title); ``archived`` soft-hides or + restores the session; ``pinned`` sets the durable keep flag (exempts the + session from the auto-archive sweep). Any field may be omitted. ``profile`` + targets another profile's session. + """ + db = _open_session_db_for_profile(body.profile) + try: + sid = db.resolve_session_id(session_id) + if not sid: + raise HTTPException(status_code=404, detail="Session not found") + if body.title is None and body.archived is None and body.pinned is None: + raise HTTPException( + status_code=400, + detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.", + ) + if body.title is not None: + try: + db.set_session_title(sid, body.title or "") + except ValueError as e: + # Title too long, invalid characters, or already in use. + raise HTTPException(status_code=400, detail=str(e)) + if body.archived is not None: + db.set_session_archived(sid, body.archived) + if body.pinned is not None: + db.set_session_pinned(sid, body.pinned) + result = {"ok": True, "title": db.get_session_title(sid) or ""} + if body.archived is not None: + result["archived"] = bool(body.archived) + if body.pinned is not None: + result["pinned"] = bool(body.pinned) + return result + finally: + db.close() + + +@manage_router.get("/api/sessions/{session_id}/export") +async def export_session_endpoint(session_id: str, profile: Optional[str] = None): + """Export a single session (metadata + messages) as JSON.""" + def _export(): + db = _open_session_db_for_profile(profile) + try: + sid = db.resolve_session_id(session_id) + return db.export_session(sid) if sid else None + finally: + db.close() + + data = await asyncio.to_thread(_export) + if data is None: + raise HTTPException(status_code=404, detail="Session not found") + return data + + +@manage_router.post("/api/sessions/prune") +async def prune_sessions_endpoint(body: SessionPrune): + """Delete ended sessions matching filters without blocking the event loop.""" + return await asyncio.to_thread(_prune_sessions, body) diff --git a/hermes_cli/web_routers/skills.py b/hermes_cli/web_routers/skills.py new file mode 100644 index 00000000000..bf83c1ee29a --- /dev/null +++ b/hermes_cli/web_routers/skills.py @@ -0,0 +1,490 @@ +"""Skills dashboard routes (extracted verbatim from web_server.py). + +Two routers because the original registration points are far apart and global +route order matters: ``hub_router`` (the skills-hub install/search/scan +endpoints) was registered before the profiles ``router`` include in +web_server, the plain skills CRUD ``router`` after it - each is mounted at +its original registration point. + +Handler bodies are byte-identical; web_server-owned helpers are reached via +the late-binding seam in :mod:`hermes_cli.web_deps` so tests that +``monkeypatch.setattr(web_server, "_spawn_hermes_action", ...)`` keep +working. +""" + +import asyncio # noqa: F401 — used by handlers +import logging +from typing import Optional # noqa: F401 + +from fastapi import APIRouter, HTTPException # noqa: F401 + +from hermes_cli.web_deps import late, LateState +from hermes_cli.web_models import ( + SkillContentUpdate, + SkillCreate, + SkillInstallRequest, + SkillToggle, + SkillUninstallRequest, + SkillsUpdateRequest, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +hub_router = APIRouter() +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_clear_skills_prompt_cache = late("_clear_skills_prompt_cache") +_config_profile_scope = late("_config_profile_scope") +_hub_action_name = late("_hub_action_name") +_installed_hub_identifiers = late("_installed_hub_identifiers") +_profile_cli_args = late("_profile_cli_args") +_profile_scope = late("_profile_scope") +_skill_meta_to_payload = late("_skill_meta_to_payload") +_spawn_hermes_action = late("_spawn_hermes_action") +load_config = late("load_config") + +# Live proxies for web_server-owned module state (mutations/monkeypatches +# on web_server remain authoritative; resolved at operation time). +_SKILL_HUB_SOURCE_LABELS = LateState("_SKILL_HUB_SOURCE_LABELS") + + +@hub_router.post("/api/skills/hub/install") +async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None): + identifier = (body.identifier or "").strip() + if not identifier: + raise HTTPException(status_code=400, detail="identifier is required") + name = _hub_action_name("install", identifier) + try: + proc = _spawn_hermes_action( + _profile_cli_args(body.profile or profile) + + ["skills", "install", identifier, "--yes"], + name, + ) + 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}") + return {"ok": True, "pid": proc.pid, "name": name} + + +@hub_router.post("/api/skills/hub/uninstall") +async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None): + name = (body.name or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name is required") + action = _hub_action_name("uninstall", name) + try: + proc = _spawn_hermes_action( + _profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"], + action, + ) + 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": action} + + +@hub_router.post("/api/skills/hub/update") +async def update_skills_hub( + body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None +): + try: + effective = (body.profile if body else None) or profile + proc = _spawn_hermes_action( + _profile_cli_args(effective) + ["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}") + return {"ok": True, "pid": proc.pid, "name": "skills-update"} + + +@hub_router.get("/api/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(): + from tools.skills_hub import create_source_router + + with _config_profile_scope(profile): + sources = create_source_router() + out = [] + index_available = False + featured = [] + for src in sources: + sid = src.source_id() + entry = { + "id": sid, + "label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid), + } + # GitHub exposes a rate-limit flag; the index an availability flag. + if sid == "github": + try: + entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False)) + except Exception: + entry["rate_limited"] = False + if sid == "hermes-index": + try: + index_available = bool(getattr(src, "is_available", False)) + except Exception: + index_available = False + entry["available"] = index_available + # Empty-query search on the index returns featured/popular skills. + if index_available: + try: + featured = [ + _skill_meta_to_payload(m) for m in src.search("", limit=12) + ] + except Exception: + featured = [] + out.append(entry) + # Tell the UI which sources are worth searching individually (for its + # progressive per-source fan-out). Mirror parallel_search_sources: when + # the centralized index is available it already subsumes the external + # API sources, so they're redundant — skipping them avoids ~70 GitHub + # calls per keystroke. Keep this set in sync with that function's + # ``_api_source_ids``. + _api_source_ids = frozenset( + {"github", "skills-sh", "clawhub", "lobehub", "well-known"} + ) + for entry in out: + entry["searchable"] = not (index_available and entry["id"] in _api_source_ids) + return { + "sources": out, + "index_available": index_available, + "featured": featured, + "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}") + + +@hub_router.get("/api/skills/hub/search") +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 + loop isn't blocked. Returns structured results the UI installs by + identifier via POST /api/skills/hub/install, previews via + /api/skills/hub/preview, and scans via /api/skills/hub/scan. + """ + query = (q or "").strip() + if not query: + return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}} + + def _run(): + from tools.skills_hub import create_source_router, parallel_search_sources + + with _config_profile_scope(profile): + sources = create_source_router() + capped = min(max(limit, 1), 50) + all_results, source_counts, timed_out = parallel_search_sources( + sources, query=query, source_filter=source or "all", overall_timeout=30 + ) + + # Dedupe by identifier, preferring higher trust (mirrors unified_search). + _rank = {"builtin": 2, "trusted": 1, "community": 0} + seen = {} + for r in all_results: + if r.identifier not in seen: + seen[r.identifier] = r + elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0): + seen[r.identifier] = r + deduped = list(seen.values())[:capped] + + return { + "results": [_skill_meta_to_payload(m) for m in deduped], + "source_counts": source_counts, + "timed_out": timed_out, + "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}") + + +@hub_router.get("/api/skills/hub/preview") +async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None): + """Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading. + + Resolves the identifier across configured sources (same path the CLI + installer uses), then returns the rendered SKILL.md text and the file + manifest WITHOUT installing anything. This is the 'read the actual skill + before installing' affordance the Browse-hub tab was missing. + + Scoped to ``profile`` so a non-default profile with different hub taps + resolves against ITS source router, not the default profile's. + """ + ident = (identifier or "").strip() + if not ident: + raise HTTPException(status_code=400, detail="identifier is required") + + def _run(): + from hermes_cli.skills_hub import _resolve_source_meta_and_bundle + from tools.skills_hub import create_source_router + + with _config_profile_scope(profile): + sources = create_source_router() + meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources) + if not bundle and not meta: + return None + + files = {} + skill_md = "" + if bundle: + for rel, content in (bundle.files or {}).items(): + if isinstance(content, bytes): + # Some sources (e.g. official optional skills) store every + # file as bytes. Decode text so SKILL.md / docs render; + # only fall back to a placeholder for genuinely-binary data. + try: + files[rel] = content.decode("utf-8") + except UnicodeDecodeError: + files[rel] = "(binary file)" + else: + files[rel] = content + skill_md = files.get("SKILL.md", "") or "" + + m = meta or bundle + return { + "name": getattr(m, "name", ident), + "description": getattr(m, "description", "") or "", + "source": getattr(m, "source", "") or "", + "identifier": getattr(m, "identifier", ident) or ident, + "trust_level": getattr(m, "trust_level", "community") or "community", + "repo": getattr(m, "repo", None), + "tags": list(getattr(m, "tags", None) or []), + "skill_md": skill_md, + "files": sorted(files.keys()), + } + + try: + result = await asyncio.to_thread(_run) + except Exception as exc: + _log.exception("skills hub preview failed") + raise HTTPException(status_code=502, detail=f"Hub preview failed: {exc}") + if result is None: + raise HTTPException(status_code=404, detail=f"Skill not found: {ident}") + return result + + +@hub_router.get("/api/skills/hub/scan") +async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None): + """Run the install-time security scan on a hub skill WITHOUT installing it. + + Fetches the bundle, quarantines it, and runs the same `scan_skill` / + `should_allow_install` pipeline the CLI installer uses — then cleans up the + quarantine. Returns the verdict, per-finding detail, trust tier, and the + install-policy decision so the dashboard can show a visual safety result + on demand (the 'scan' button the Browse-hub tab was missing). + + Scoped to ``profile`` so the bundle resolves against that profile's hub + source router, matching where an install would pull it from. + """ + ident = (identifier or "").strip() + if not ident: + raise HTTPException(status_code=400, detail="identifier is required") + + def _run(): + import shutil as _shutil + + from hermes_cli.skills_hub import _resolve_source_meta_and_bundle + from tools.skills_hub import create_source_router, quarantine_bundle + from tools.skills_guard import scan_skill, should_allow_install + + with _config_profile_scope(profile): + sources = create_source_router() + meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources) + if not bundle: + return None + + if bundle.source == "official": + scan_source = "official" + else: + scan_source = ( + getattr(bundle, "identifier", "") + or getattr(meta, "identifier", "") + or ident + ) + + q_path = None + try: + q_path = quarantine_bundle(bundle) + result = scan_skill(q_path, source=scan_source) + finally: + if q_path is not None: + _shutil.rmtree(q_path, ignore_errors=True) + + allowed, reason = should_allow_install(result, force=False) + # `allowed` may be None ("ask") for agent-created/dangerous gates. + if allowed is True: + policy = "allow" + elif allowed is None: + policy = "ask" + else: + policy = "block" + + findings = [ + { + "severity": f.severity, + "category": f.category, + "file": f.file, + "line": f.line, + "description": f.description, + } + for f in result.findings + ] + # Per-severity tally for an at-a-glance summary. + counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} + for f in result.findings: + if f.severity in counts: + counts[f.severity] += 1 + + return { + "name": result.skill_name, + "identifier": ident, + "source": result.source, + "trust_level": result.trust_level, + "verdict": result.verdict, + "summary": result.summary, + "policy": policy, + "policy_reason": reason, + "findings": findings, + "severity_counts": counts, + } + + try: + result = await asyncio.to_thread(_run) + except Exception as exc: + _log.exception("skills hub scan failed") + raise HTTPException(status_code=502, detail=f"Hub scan failed: {exc}") + if result is None: + raise HTTPException(status_code=404, detail=f"Skill not found: {ident}") + return result + + +@router.get("/api/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 + from tools.skill_usage import ( + _read_bundled_manifest_names, + _read_hub_installed_names, + activity_count, + load_usage, + ) + with _profile_scope(profile): + config = load_config() + disabled = get_disabled_skills(config) + skills = _find_all_skills(skip_disabled=True) + usage = load_usage() + # Set-based provenance (same classification as skill_usage.provenance, + # without a per-skill manifest read): hub > bundled > agent, where + # "agent" covers agent-authored AND local hand-made skills — the ones + # the user may edit/delete from the UI. + bundled_names = _read_bundled_manifest_names() + hub_names = _read_hub_installed_names() + for s in skills: + s["enabled"] = s["name"] not in disabled + s["usage"] = activity_count(usage.get(s["name"], {})) + s["provenance"] = ( + "hub" if s["name"] in hub_names + else "bundled" if s["name"] in bundled_names + else "agent" + ) + return skills + + +@router.put("/api/skills/toggle") +async def toggle_skill(body: SkillToggle, profile: Optional[str] = None): + from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills + with _profile_scope(body.profile or 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} + + +@router.get("/api/skills/content") +async def get_skill_content(name: str, profile: Optional[str] = None): + """Return the raw SKILL.md text for a skill, for the dashboard editor.""" + from tools.skill_manager_tool import _find_skill + + with _profile_scope(profile): + found = _find_skill(name) + if not found: + raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.") + skill_md = found["path"] / "SKILL.md" + if not skill_md.exists(): + raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.") + try: + content = skill_md.read_text(encoding="utf-8") + except OSError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc + return {"name": name, "content": content, "path": str(skill_md)} + + +@router.post("/api/skills") +async def create_skill(body: SkillCreate): + """Create a new custom skill (SKILL.md) from the dashboard editor. + + Calls the same validated write path as the agent's ``skill_manage`` + tool (frontmatter validation, name/category validation, size limit, + optional security scan) — but bypasses the agent write-approval gate: + a write from the authenticated dashboard IS the user acting directly. + """ + from tools.skill_manager_tool import _create_skill + + with _profile_scope(body.profile): + result = _create_skill(body.name, body.content, body.category or None) + if not result.get("success"): + raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill.")) + _clear_skills_prompt_cache() + return result + + +@router.put("/api/skills/content") +async def update_skill_content(body: SkillContentUpdate): + """Replace the SKILL.md of an existing skill (full rewrite) from the editor.""" + from tools.skill_manager_tool import _edit_skill + + with _profile_scope(body.profile): + result = _edit_skill(body.name, body.content) + if not result.get("success"): + err = result.get("error", "Failed to update skill.") + status = 404 if "not found" in str(err).lower() else 400 + raise HTTPException(status_code=status, detail=err) + _clear_skills_prompt_cache() + return result diff --git a/hermes_cli/web_routers/tools.py b/hermes_cli/web_routers/tools.py new file mode 100644 index 00000000000..e96baa6f6b5 --- /dev/null +++ b/hermes_cli/web_routers/tools.py @@ -0,0 +1,734 @@ +"""Toolset / terminal-backend dashboard routes (extracted verbatim from +web_server.py). + +Handler bodies are byte-identical. The toolset/terminal catalogs +(``_MODEL_CATALOG_TOOLSETS``, ``_TERMINAL_BACKENDS``, +``_TERMINAL_BACKEND_NAMES`` - some defined *after* this router's mount point +in web_server's body) and all helpers stay in web_server - reached via the +late-binding seam in :mod:`hermes_cli.web_deps` (``late`` for callables, +``LateState`` for the catalog constants) so monkeypatching on web_server +stays authoritative. +""" + +import logging +import sys # noqa: F401 — used by handlers +from typing import Any, Dict, List, Optional # noqa: F401 + +from fastapi import APIRouter, HTTPException # noqa: F401 + +from hermes_cli.web_deps import late, LateState +from hermes_cli.web_models import ( + TerminalBackendSelect, + ToolsetEnvUpdate, + ToolsetModelSelect, + ToolsetPostSetup, + ToolsetProviderSelect, + ToolsetToggle, +) + +# Same logger the handlers used before extraction (identical logger object). +_log = logging.getLogger("hermes_cli.web_server") + +router = APIRouter() + +# Late-bound web_server helpers (resolved at call time; cycle-safe, +# monkeypatch-transparent). +_find_toolset_provider_row = late("_find_toolset_provider_row") +_probe_terminal_backend = late("_probe_terminal_backend") +_profile_cli_args = late("_profile_cli_args") +_profile_scope = late("_profile_scope") +_resolve_toolset_model_plugin = late("_resolve_toolset_model_plugin") +_spawn_hermes_action = late("_spawn_hermes_action") +_toolset_model_catalog = late("_toolset_model_catalog") +load_config = late("load_config") +save_config = late("save_config") + +# Live proxies for web_server-owned module state (mutations/monkeypatches +# on web_server remain authoritative; resolved at operation time). +_MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS") +_TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS") +_TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES") + + +@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, + gui_toolset_label, + ) + from hermes_cli.platforms import platform_label + from toolsets import resolve_toolset + + 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 + } + 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). + from utils import is_truthy_value + + 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), + "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. + + 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 ( + _CONFIG_ONLY_TOOLSETS, + _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) + if name in _CONFIG_ONLY_TOOLSETS: + # Config-only capabilities (stt) toggle their own config section's + # ``enabled`` flag — there is no platform_toolsets entry to write. + with _profile_scope(body.profile or profile): + config = load_config() + section = config.setdefault(name, {}) + if not isinstance(section, dict): + section = {} + config[name] = section + section["enabled"] = bool(body.enabled) + save_config(config) + return { + "ok": True, + "name": name, + "platform": target_platform, + "enabled": body.enabled, + } + with _profile_scope(body.profile or profile): + config = load_config() + 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) + return { + "ok": True, + "name": name, + "platform": target_platform, + "enabled": body.enabled, + } + + +@router.get("/api/tools/toolsets/{name}/config") +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 + (via ``_visible_providers``), each with its ``env_vars`` annotated with + current ``is_set`` state so the GUI can render provider selection + key + entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty + provider list and ``has_category: false``. Returns 400 for unknown keys. + """ + from hermes_cli.tools_config import ( + TOOL_CATEGORIES, + _get_effective_configurable_toolsets, + _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 + + 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}") + + with _profile_scope(profile): + config = load_config() + cat = TOOL_CATEGORIES.get(name) + providers = [] + active_provider = None + active_search_backend = None + active_extract_backend = None + if cat: + # Fetch portal/entitlement state once for the whole matrix — the + # per-provider readiness computation below reuses it instead of + # re-probing per row. + 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", []) + ] + # 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"] + 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, + # Honest server-side readiness. The GUI's old client-side + # heuristic showed "Ready" for every zero-env-var row — + # including logged-out Nous Subscription rows and never-run + # post_setup installs (see provider_readiness_status). + "status": provider_readiness_status( + prov, config, features=features, is_active=is_active + ), + } + if name == "web" and prov.get("web_backend"): + # The runtime split web into two capabilities long ago + # (web.search_backend / web.extract_backend); surface each + # row's backend key and which capabilities it can serve so + # the GUI can offer 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"): + # The provider key written to tts.provider on selection. + # Doubles as the config section holding the provider's + # voice/model settings (tts..*) so the GUI can render + # those fields inline in the Capabilities panel. + row["tts_provider"] = prov["tts_provider"] + providers.append(row) + if name == "web": + # Resolve the per-capability active backends exactly the way the + # web_search / web_extract dispatchers do (per-capability key → + # shared web.backend → credential auto-detect), so the GUI badges + # reflect what a tool call would actually hit right now. + try: + from tools.web_tools import _get_extract_backend, _get_search_backend + + active_search_backend = _get_search_backend() + active_extract_backend = _get_extract_backend() + except Exception: + active_search_backend = None + active_extract_backend = None + payload = { + "name": name, + "has_category": cat is not None, + "providers": providers, + "active_provider": active_provider, + } + if name == "web": + payload["active_search_backend"] = active_search_backend + payload["active_extract_backend"] = active_extract_backend + return payload + + +@router.get("/api/tools/toolsets/{name}/models") +async def get_toolset_models( + name: str, provider: Optional[str] = None, profile: Optional[str] = None +): + """Return the model catalog for a toolset backend (image/video gen). + + The GUI counterpart of the model picker `hermes tools` runs after a + backend is selected — e.g. FAL's multi-model catalog (speed / strengths / + price per model). ``provider`` names a picker row; omitted, the currently + active provider is used. Toolsets without model catalogs return + ``has_models: false``. + """ + section = _MODEL_CATALOG_TOOLSETS.get(name) + if section is None: + return {"name": name, "has_models": False, "models": [], "current": None, "default": None} + + 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 { + "name": name, + "has_models": False, + "models": [], + "current": None, + "default": 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") if row else None, + "plugin": plugin, + "models": models, + "current": current, + "default": default_model, + } + + +@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``). + + Validates the model against the resolved backend's catalog — the same + write the CLI's post-selection model picker performs. Returns 400 for + toolsets without model catalogs or unknown model ids. + """ + section = _MODEL_CATALOG_TOOLSETS.get(name) + if section is None: + raise HTTPException( + status_code=400, detail=f"Toolset has no model catalog: {name}" + ) + + model_id = (body.model or "").strip() + if not model_id: + raise HTTPException(status_code=400, detail="model is required") + + with _profile_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 HTTPException( + status_code=400, + detail=f"No model-capable backend is active for {name}", + ) + + catalog, _default = _toolset_model_catalog(name, plugin) + if model_id not in catalog: + raise HTTPException( + status_code=400, + detail=f"Unknown model {model_id!r} for backend {plugin!r}", + ) + + section_cfg = config.setdefault(section, {}) + if not isinstance(section_cfg, dict): + section_cfg = {} + config[section] = section_cfg + section_cfg["model"] = model_id + save_config(config) + + 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 for a toolset (no key prompting). + + Delegates to ``apply_provider_selection`` — the shared, non-interactive + core extracted from the CLI configurator — so the GUI and ``hermes tools`` + write identical config keys (``web.backend``, ``tts.provider``, etc.). + API keys and post-setup flows are handled by separate endpoints. Returns + 400 for unknown toolset or provider names. + + For the ``web`` toolset only, an optional ``capability`` ('search' | + 'extract') scopes the selection to ``web.search_backend`` / + ``web.extract_backend`` — the same per-capability overrides the runtime + dispatchers (``tools.web_tools._get_search_backend`` / + ``_get_extract_backend``) resolve first. The provider must actually + support the requested capability (a search-only backend can't be the + extract backend). Omitting ``capability`` keeps the legacy whole-provider + behavior (writes ``web.backend``). + + Managed Nous rows (``managed_nous_feature``) additionally report the + Portal entitlement state: the CLI flow gates these selections on + ``ensure_nous_portal_access`` (inline login), but the GUI has no inline + prompt, so selecting one while logged out / unentitled used to write the + config keys and then never activate (``_is_provider_active`` requires + ``managed_by_nous``). The response now carries an additive + ``needs_nous_auth: true`` + ``feature`` so the client can drive the + existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``) + and refetch. + """ + from hermes_cli.tools_config import ( + TOOL_CATEGORIES, + apply_provider_selection, + web_provider_capabilities, + _get_effective_configurable_toolsets, + _visible_providers, + ) + from hermes_cli.nous_subscription import ( + MANAGED_FEATURE_COVERAGE_CATEGORY, + get_nous_subscription_features, + ) + + 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}") + + if body.capability is not None: + if name != "web": + raise HTTPException( + status_code=400, + detail="capability selection is only supported for the web toolset", + ) + if body.capability not in ("search", "extract"): + raise HTTPException( + status_code=400, + detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')", + ) + + with _profile_scope(body.profile or profile): + config = load_config() + if body.capability is not None: + # Per-capability path: resolve the picker row to its backend key + # and write web._backend. Does NOT touch web.backend, + # so the other capability keeps resolving through the shared + # fallback chain. + cat = TOOL_CATEGORIES.get(name) + providers = _visible_providers(cat, config, force_fresh=True) if cat else [] + prov = next((p for p in providers if p.get("name") == body.provider), None) + if prov is None: + raise HTTPException( + status_code=400, + detail=f"Unknown provider {body.provider!r} for toolset {name!r}", + ) + backend = prov.get("web_backend") + if not backend: + raise HTTPException( + status_code=400, + detail=f"Provider {body.provider!r} has no web backend key", + ) + if body.capability not in web_provider_capabilities(backend): + raise HTTPException( + status_code=400, + detail=f"{body.provider} does not support {body.capability}", + ) + web_cfg = config.setdefault("web", {}) + if not isinstance(web_cfg, dict): + web_cfg = {} + config["web"] = web_cfg + web_cfg[f"{body.capability}_backend"] = backend + else: + try: + apply_provider_selection(name, body.provider, config) + except KeyError as exc: + raise HTTPException(status_code=400, detail=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 gate the CLI + # applies via ensure_nous_portal_access at selection time. + cat = TOOL_CATEGORIES.get(name) + row = None + if cat: + row = next( + ( + p + for p in _visible_providers(cat, config, force_fresh=True) + if p.get("name") == body.provider + ), + None, + ) + 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 + + +@router.put("/api/tools/toolsets/{name}/env") +async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None): + """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}") + + with _profile_scope(body.profile or 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))}", + ) + + 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} + + +@router.post("/api/tools/toolsets/{name}/post-setup") +async def run_toolset_post_setup( + name: str, body: ToolsetPostSetup, profile: Optional[str] = None +): + """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. + + ``profile`` spawns the hook as ``hermes -p tools post-setup``. + Most hooks install machine-level artifacts (repo node_modules, shared + pip packages) where the scope is inert, but hooks that read config or + write per-profile state must see the same HERMES_HOME the rest of the + drawer's writes targeted — so the scope is threaded for consistency. + """ + 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( + _profile_cli_args(body.profile or profile) + + ["tools", "post-setup", body.key], + "tools-post-setup", + ) + except HTTPException: + raise + 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} + + +@router.get("/api/tools/terminal/backends") +async def get_terminal_backends(profile: Optional[str] = None): + """Terminal execution backend rows with health probes for the picker panel. + + Returns ``{active, backends: [{name, label, description, active, status, + detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` / + ``unavailable`` and ``detail`` carries setup guidance for non-ready rows. + Probes are fast (<~2s each) and defensive — a probe failure surfaces as a + status, never an error response. + """ + with _profile_scope(profile): + config = load_config() + terminal_cfg = config.get("terminal") + if not isinstance(terminal_cfg, dict): + terminal_cfg = {} + active = str(terminal_cfg.get("backend") or "local").strip().lower() + if active not in _TERMINAL_BACKEND_NAMES: + active = "local" + + backends = [] + for row in _TERMINAL_BACKENDS: + 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} + + +@router.put("/api/tools/terminal/backend") +async def select_terminal_backend( + body: TerminalBackendSelect, profile: Optional[str] = None +): + """Persist ``terminal.backend`` in config.yaml. + + Validates against the known backend set (the same enum the raw-config + settings row exposes). Selecting a backend that still needs setup is + allowed — the picker shows guidance instead of blocking, matching the CLI. + """ + backend = (body.backend or "").strip().lower() + if backend not in _TERMINAL_BACKEND_NAMES: + raise HTTPException( + status_code=400, + detail=f"Unknown terminal backend: {body.backend!r}. " + f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}", + ) + + with _profile_scope(body.profile or profile): + config = load_config() + terminal_cfg = config.setdefault("terminal", {}) + if not isinstance(terminal_cfg, dict): + terminal_cfg = {} + config["terminal"] = terminal_cfg + terminal_cfg["backend"] = backend + save_config(config) + return {"ok": True, "backend": backend} + + +@router.get("/api/tools/computer-use/status") +async def get_computer_use_status(profile: Optional[str] = None): + """Cross-platform Computer Use readiness for the desktop card. + + See ``tools.computer_use.permissions.computer_use_status`` for the payload + shape. Read-only and fast (shells ``cua-driver doctor`` + macOS + ``permissions status``). + """ + from tools.computer_use.permissions import computer_use_status + + with _profile_scope(profile): + return 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`` as a background action. + + macOS-only: ``cua-driver permissions grant`` launches CuaDriver via + LaunchServices so the TCC dialog is attributed to com.trycua.driver, then + waits for approval. The frontend polls ``GET /api/actions/computer-use- + grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have + no TCC toggles to grant, so this returns 400 there. + """ + if sys.platform != "darwin": + raise HTTPException( + status_code=400, + detail="Computer Use permission grants are a macOS concept.", + ) + try: + proc = _spawn_hermes_action( + _profile_cli_args(profile) + + ["computer-use", "permissions", "grant"], + "computer-use-grant", + ) + except HTTPException: + raise + except Exception as exc: + _log.exception("Failed to spawn computer-use permissions grant") + raise HTTPException( + status_code=500, detail=f"Failed to request permissions: {exc}" + ) + return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"} diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 49613d11437..cf068da5574 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4683,117 +4683,12 @@ def _strip_session_list_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, A return sessions -@app.get("/api/sessions") -def get_sessions( - limit: int = 20, - offset: int = 0, - min_messages: int = 0, - archived: str = "exclude", - order: str = "created", - source: str = None, - sources: str = None, - exclude_sources: str = None, - cwd_prefix: str = None, - full: bool = False, - profile: Optional[str] = None, -): - """List sessions. +from hermes_cli.web_routers import sessions as _sessions_routes # noqa: E402 - ``archived`` controls how soft-archived sessions are treated: - ``exclude`` (default) hides them, ``only`` returns just the archived ones - (used by the desktop "Archived sessions" settings panel), and ``include`` - returns both. - - ``order`` controls pagination order: ``created`` (default, by original - start time) or ``recent`` (by latest activity across the compression - chain). ``recent`` keeps a long-running conversation on the first page - after it auto-compresses into a fresh continuation id. - - Rows omit ``system_prompt``/``model_config`` (the payload-dominating - fields no list UI reads) unless ``full=1`` is passed. - """ - if archived not in ("exclude", "only", "include"): - raise HTTPException( - status_code=400, - detail="archived must be one of: exclude, only, include", - ) - if order not in ("created", "recent"): - raise HTTPException( - status_code=400, - detail="order must be one of: created, recent", - ) - profile_name: Optional[str] = None - if profile: - profile_name, _ = _cron_profile_home(profile) - try: - db = _open_session_db_for_profile(profile) - try: - # Opportunistic, config-gated, double-throttled stale-session - # sweep — the only auto_archive hook that fires for Desktop's - # `hermes serve` backend. No-op when disabled or run recently. - _maybe_auto_archive_for_profile(db, profile) - min_message_count = max(0, min_messages) - archived_only = archived == "only" - include_archived = archived == "include" - # Optional source scoping: ``source`` includes a single class, - # ``sources`` includes any of several comma-separated classes, and - # ``exclude_sources`` (comma-separated) drops classes. The desktop - # uses these to split recents (exclude=cron) from the cron-jobs - # section (source=cron) into two independent lists. - source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] - exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] - sessions = db.list_sessions_rich( - source=source or None, - sources=source_list or None, - exclude_sources=exclude_list or None, - cwd_prefix=(cwd_prefix or None), - limit=limit, - offset=offset, - min_message_count=min_message_count, - include_archived=include_archived, - archived_only=archived_only, - order_by_last_active=order == "recent", - # SQL-level projection: when the caller didn't ask for full - # rows, skip the system_prompt blob inside SQLite too (pairs - # with the API-level _strip_session_list_rows below). - compact_rows=not full, - include_pinned=True, - ) - total = db.session_count( - source=source or None, - sources=source_list or None, - cwd_prefix=(cwd_prefix or None), - exclude_sources=exclude_list or None, - min_message_count=min_message_count, - include_archived=include_archived, - archived_only=archived_only, - exclude_children=True, - ) - now = time.time() - # Same ownership contract as get_session_detail: rows are stamped - # with the serving profile even when the request wasn't explicitly - # scoped, so default-profile rows never circulate unowned. - row_profile = profile_name or _cron_default_profile() - for s in sessions: - s["is_active"] = ( - s.get("ended_at") is None - and (now - s.get("last_active", s.get("started_at", 0))) < 300 - ) - s["profile"] = row_profile - s["is_default_profile"] = row_profile == "default" - # SQLite stores the flag as 0/1; expose a real JSON boolean. - s["archived"] = bool(s.get("archived")) - s["pinned"] = bool(s.get("pinned")) - if not full: - _strip_session_list_rows(sessions) - return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} - finally: - db.close() - except HTTPException: - raise - except Exception: - _log.exception("GET /api/sessions failed") - raise HTTPException(status_code=500, detail="Internal server error") +app.include_router(_sessions_routes.list_router) +from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + get_sessions, +) from hermes_cli.web_routers import profiles as _profiles_routes # noqa: E402 @@ -4807,222 +4702,10 @@ from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-e -@app.get("/api/sessions/search") -async def search_sessions( - q: str = "", - limit: int = 20, - profile: Optional[str] = None, - source: str = None, - sources: str = None, - exclude_sources: str = None, -): - """Search sessions by ID plus full-text message content using FTS5. - - Direct session-id matches are surfaced first, then FTS message-content - matches. Results are deduped by compression lineage, not by raw - ``session_id``. Auto-compression rotates a conversation onto a fresh - session id (and leaves the old segment's messages in the FTS index), so one - logical chat can own many ``sessions`` rows that all match the same query. - Branches also use ``parent_session_id``, but they are real alternate - conversations; don't collapse branch-specific hits back into the parent. - """ - if not q or not q.strip(): - return {"results": []} - try: - db = _open_session_db_for_profile(profile) - try: - safe_limit = max(1, min(int(limit or 20), 100)) - source_filter = source or None - source_list = [s.strip() for s in (sources or "").split(",") if s.strip()] - include_sources = [source_filter] if source_filter else (source_list or None) - exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()] - now = time.time() - - # Walk parent_session_id to the compression root, memoized so a - # chain of compression segments only costs one walk. We deliberately - # stop at branch/delegate edges: those sessions may diverge from the - # parent and should remain searchable on their own. - root_cache: dict = {} - - def compression_root(session_id: str) -> str: - if not session_id: - return session_id - if session_id in root_cache: - return root_cache[session_id] - chain = [] - cur = session_id - visited = set() - root = session_id - while cur and cur not in visited: - visited.add(cur) - chain.append(cur) - if cur in root_cache: - root = root_cache[cur] - break - try: - s = db.get_session(cur) - except Exception: - s = None - if not s: - root = cur - break - parent = s.get("parent_session_id") if isinstance(s, dict) else None - if not parent: - root = cur - break - try: - parent_session = db.get_session(parent) - except Exception: - parent_session = None - if not parent_session: - root = cur - break - parent_ended_at = parent_session.get("ended_at") - started_at = s.get("started_at") - is_compression_edge = ( - parent_session.get("end_reason") == "compression" - and parent_ended_at is not None - and started_at is not None - and started_at >= parent_ended_at - ) - if not is_compression_edge: - root = cur - break - cur = parent - for node in chain: - root_cache[node] = root - return root - - tip_cache: dict = {} - - def lineage_tip(root_id: str) -> str: - if root_id in tip_cache: - return tip_cache[root_id] - tip = root_id - try: - resolved = db.get_compression_tip(root_id) - if resolved: - tip = resolved - except Exception: - pass - tip_cache[root_id] = tip - return tip - - # Both ID matches and content matches share one keyspace, keyed by - # compression lineage root, so an id-hit and a content-hit on the - # same logical conversation collapse to a single result. The first - # hit for a lineage wins; ID matches run first and take priority. - seen: dict = {} - - def add_lineage_result(raw_sid: str, payload: dict) -> None: - if not raw_sid: - return - root = compression_root(raw_sid) - if root in seen or len(seen) >= safe_limit: - return - payload = dict(payload) - sid = lineage_tip(root) - payload["session_id"] = sid - payload["lineage_root"] = root - try: - row = db.get_session_rich_row(sid) - except Exception: - row = None - if row: - payload.update( - { - "id": row.get("id") or sid, - "source": row.get("source"), - "model": row.get("model"), - "title": row.get("title"), - "started_at": row.get("started_at"), - "ended_at": row.get("ended_at"), - "last_active": row.get("last_active") or row.get("started_at"), - "is_active": ( - row.get("ended_at") is None - and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300 - ), - "message_count": row.get("message_count") or 0, - "tool_call_count": row.get("tool_call_count") or 0, - "input_tokens": row.get("input_tokens") or 0, - "output_tokens": row.get("output_tokens") or 0, - "preview": row.get("preview"), - "parent_session_id": row.get("parent_session_id"), - "archived": bool(row.get("archived")), - } - ) - else: - payload["id"] = sid - seen[root] = payload - - # Direct ID matches first: users often paste a session id from CLI, - # logs, or another Hermes surface. FTS can't find those unless the - # id happens to appear in message text. search_sessions_by_id is - # SQL-bounded, so this stays cheap even with thousands of sessions. - for row in db.search_sessions_by_id( - q, - limit=safe_limit, - include_archived=True, - source=source_filter, - sources=source_list or None, - exclude_sources=exclude_list or None, - ): - sid = row.get("id") - preview = (row.get("preview") or "").strip() - snippet = preview or f"Session ID: {sid}" - add_lineage_result( - sid, - { - "snippet": snippet, - "role": None, - "source": row.get("source"), - "model": row.get("model"), - "session_started": row.get("started_at"), - }, - ) - - # Auto-add prefix wildcards so partial words match - # e.g. "nimb" → "nimb*" matches "nimby" - # Preserve quoted phrases and existing wildcards as-is - import re - terms = [] - for token in re.findall(r'"[^"]*"|\S+', q.strip()): - if token.startswith('"') or token.endswith("*"): - terms.append(token) - else: - terms.append(token + "*") - prefix_query = " ".join(terms) - # Over-fetch so lineage dedup can still surface `limit` distinct - # conversations even when several hits collapse onto one root. - fetch_limit = max(safe_limit * 5, 50) - matches = db.search_messages( - query=prefix_query, - source_filter=include_sources, - exclude_sources=exclude_list or None, - limit=fetch_limit, - ) - - for m in matches: - if len(seen) >= safe_limit: - break - add_lineage_result( - m["session_id"], - { - "snippet": m.get("snippet", ""), - "role": m.get("role"), - "source": m.get("source"), - "model": m.get("model"), - "session_started": m.get("session_started"), - }, - ) - return {"results": list(seen.values())} - finally: - db.close() - except HTTPException: - raise - except Exception: - _log.exception("GET /api/sessions/search failed") - raise HTTPException(status_code=500, detail="Search failed") +app.include_router(_sessions_routes.search_router) +from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + search_sessions, +) def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: @@ -11267,164 +10950,29 @@ def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str db.close() -@app.post("/api/sessions/bulk-delete") -async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions): - """Delete every session in ``body.ids`` in a single DB transaction. - - Backs the dashboard's bulk-select-and-delete flow on the sessions - page. POST (not DELETE) because most HTTP clients refuse to send a - request body on DELETE and a body is the natural shape for a list - of IDs — Starlette accepts both, but POSTing a list keeps proxies, - curl, and the browser ``fetch`` API consistent. - - Per-row contract matches :meth:`SessionDB.delete_sessions`: - - * Unknown IDs are silently skipped (the response ``deleted`` count - reflects what really happened, not the input length). This is - deliberate — UI selection state can race against another tab's - delete, and we'd rather succeed-on-the-rest than fail-the-whole- - batch. - * Children of every deleted parent are orphaned, not cascade- - deleted. - * Active and archived sessions ARE deleted when explicitly - selected — unlike ``DELETE /api/sessions/empty``, the user - hand-picked the rows so we trust the selection. - * Like the other session-delete endpoints, this does NOT pass a - ``sessions_dir`` through; on-disk transcript / request-dump - cleanup runs at the CLI/agent layer on the next prune pass. - - The response carries the actual deleted count, so the dashboard - can surface it in a toast. The IDs that were removed are not - echoed back because the client already knows what it asked to - delete (unknown IDs are silently skipped — see contract above) - and can prune its in-memory list directly from the request. - """ - # Enforce a hard cap so a runaway/typo'd selection can't lock the - # DB writer for an extended window. The dashboard pages 20 rows - # at a time; 500 covers a "select all on every page in a - # reasonable scrollback" worst case without opening the door to - # multi-thousand-row transactions. - if len(body.ids) > 500: - raise HTTPException( - status_code=400, - detail="ids must contain at most 500 entries", - ) - def _delete() -> int: - db = _open_session_db_for_profile(body.profile) - try: - return db.delete_sessions(body.ids) - finally: - db.close() - - deleted = await asyncio.to_thread(_delete) - return {"ok": True, "deleted": deleted} +app.include_router(_sessions_routes.manage_router) +from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + bulk_delete_sessions_endpoint, + import_sessions_endpoint, + count_empty_sessions_endpoint, + delete_empty_sessions_endpoint, + get_session_stats, + get_session_detail, + get_session_latest_descendant, + get_session_messages, + delete_session_endpoint, + rename_session_endpoint, + export_session_endpoint, + prune_sessions_endpoint, +) -@app.post("/api/sessions/import") -async def import_sessions_endpoint(request: Request): - """Import one or more sessions exported from the dashboard or CLI. - - This is intentionally separate from ``/api/ops/import``: that endpoint - restores a whole Hermes backup archive, while this endpoint is scoped to - session rows/messages and is safe to use from the Sessions page. - """ - try: - raw_body = await _read_session_import_body(request) - body = SessionImport.model_validate_json(raw_body) - except HTTPException: - raise - except ValueError as exc: - raise HTTPException(status_code=400, detail="Invalid session import payload") from exc - - try: - result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - if not result.get("ok", False): - raise HTTPException(status_code=400, detail=result) - return result -@app.get("/api/sessions/empty/count") -async def count_empty_sessions_endpoint(profile: Optional[str] = None): - """Return the number of empty, ended, non-archived sessions. - - Drives the dashboard's "Delete empty (N)" button — when N is 0 the - UI hides the affordance so users aren't presented with a button - that does nothing. Cheap, single-COUNT query. - """ - def _count() -> int: - db = _open_session_db_for_profile(profile) - try: - return db.count_empty_sessions() - finally: - db.close() - - return {"count": await asyncio.to_thread(_count)} -@app.delete("/api/sessions/empty") -async def delete_empty_sessions_endpoint(profile: Optional[str] = None): - """Delete every empty (``message_count == 0``), ended, - non-archived session in a single transaction. - - Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`: - - * Active sessions are skipped (``ended_at IS NULL``) so a live - agent isn't yanked mid-handshake. - * Archived sessions are skipped — the user explicitly chose to - keep those rows. - * Children of deleted parents are orphaned, not cascade-deleted. - - Like the single-session ``DELETE /api/sessions/{id}`` endpoint - below, this doesn't pass a ``sessions_dir`` through — the on-disk - transcript / request-dump cleanup is wired at the CLI/agent layer - but the web server historically leaves file cleanup to the next - prune-on-startup pass. Matching that pre-existing trade-off keeps - the two delete endpoints' DB-vs-disk behaviour consistent. - """ - def _delete() -> int: - db = _open_session_db_for_profile(profile) - try: - return db.delete_empty_sessions() - finally: - db.close() - - deleted = await asyncio.to_thread(_delete) - return {"ok": True, "deleted": deleted} -@app.get("/api/sessions/stats") -async def get_session_stats(profile: Optional[str] = None): - """Session-store statistics for the Sessions page (mirrors `hermes sessions stats`). - - Registered before ``/api/sessions/{session_id}`` so the literal ``stats`` - path isn't captured as a session id by the parameterized route. - """ - db = _open_session_db_for_profile(profile) - try: - total = db.session_count(include_archived=True) - active_store = db.session_count(include_archived=False) - archived = db.session_count(archived_only=True) - messages = db.message_count() - by_source: Dict[str, int] = {} - try: - by_source = db.session_count_by_source( - include_archived=True, - exclude_children=True, - ) - except Exception: - pass - return { - "total": total, - "active_store": active_store, - "archived": archived, - "messages": messages, - "by_source": by_source, - } - finally: - db.close() def _open_session_db_for_profile(profile: Optional[str]): @@ -11507,169 +11055,16 @@ async def _auto_archive_ticker_loop( await asyncio.sleep(interval_s) -@app.get("/api/sessions/{session_id}") -async def get_session_detail(session_id: str, profile: Optional[str] = None): - db = _open_session_db_for_profile(profile) - try: - sid = db.resolve_session_id(session_id) - session = db.get_session(sid) if sid else None - if not session: - raise HTTPException(status_code=404, detail="Session not found") - # Always stamp the owning profile — the serving profile is known even - # when the request carries no ``?profile=`` (it's this process's own - # profile). Stamping only on explicit ``?profile=`` left rows for the - # default/primary profile systematically unowned, so multi-profile - # clients resolved them to whichever gateway happened to be active - # (cross-profile open asymmetry, #67603 family). - session["profile"] = ( - _cron_profile_home(profile)[0] if profile else _cron_default_profile() - ) - session["is_default_profile"] = session["profile"] == "default" - return session - finally: - db.close() -@app.get("/api/sessions/{session_id}/latest-descendant") -async def get_session_latest_descendant( - session_id: str, - profile: Optional[str] = None, -): - def _lookup(): - db = _open_session_db_for_profile(profile) - try: - return _session_latest_descendant(session_id, db) - finally: - db.close() - - latest, path = await asyncio.to_thread(_lookup) - if not latest: - raise HTTPException(status_code=404, detail="Session not found") - return { - "requested_session_id": path[0] if path else session_id, - "session_id": latest, - "path": path, - "changed": bool(path and latest != path[0]), - } - -@app.get("/api/sessions/{session_id}/messages") -async def get_session_messages( - session_id: str, - profile: Optional[str] = None, - limit: Optional[int] = None, - offset: int = 0, -): - def _read(): - db = _open_session_db_for_profile(profile) - try: - sid = db.resolve_session_id(session_id) - if not sid: - return None - sid = db.resolve_resume_session_id(sid) - # Clamp limit to prevent abuse (max 500 per page) - _limit = min(limit, 500) if limit is not None else None - return sid, _limit, db.get_messages(sid, limit=_limit, offset=offset) - finally: - db.close() - - result = await asyncio.to_thread(_read) - if result is None: - raise HTTPException(status_code=404, detail="Session not found") - sid, _limit, messages = result - return { - "session_id": sid, - "messages": messages, - "pagination": { - "limit": _limit, - "offset": offset, - "returned": len(messages), - }, - } -@app.delete("/api/sessions/{session_id}") -async def delete_session_endpoint(session_id: str, profile: Optional[str] = None): - # ``profile`` deletes a session belonging to another (local) profile by - # opening its state.db directly. Remote profiles never reach here — the - # desktop routes their DELETE to the remote backend. Omit for current/default. - def _delete(): - db = _open_session_db_for_profile(profile) - try: - # Resolve exact ids / unique prefixes like every other session endpoint - # (detail, messages, rename, export all do). A session that no longer - # exists is an idempotent success: DELETE's contract is "ensure it's - # gone", and the desktop optimistically removes the row then RESTORES it - # on any error — so a 404 on an already-absent row resurrected a ghost - # row and surfaced "session not found". /goal + auto-compression churn - # leaves transient empty rows (reaped by empty-session hygiene) that - # race the sidebar snapshot, which is exactly when this fired. Mirrors - # the bulk-delete endpoint, which already treats ghost ids as success. - sid = db.resolve_session_id(session_id) - if not sid: - return {"ok": True, "already_absent": True} - db.delete_session(sid) - return {"ok": True} - finally: - db.close() - - return await asyncio.to_thread(_delete) -@app.patch("/api/sessions/{session_id}") -async def rename_session_endpoint(session_id: str, body: SessionRename): - """Update a session: rename, archive, and/or pin it. - - ``title`` renames (empty/null clears the title); ``archived`` soft-hides or - restores the session; ``pinned`` sets the durable keep flag (exempts the - session from the auto-archive sweep). Any field may be omitted. ``profile`` - targets another profile's session. - """ - db = _open_session_db_for_profile(body.profile) - try: - sid = db.resolve_session_id(session_id) - if not sid: - raise HTTPException(status_code=404, detail="Session not found") - if body.title is None and body.archived is None and body.pinned is None: - raise HTTPException( - status_code=400, - detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.", - ) - if body.title is not None: - try: - db.set_session_title(sid, body.title or "") - except ValueError as e: - # Title too long, invalid characters, or already in use. - raise HTTPException(status_code=400, detail=str(e)) - if body.archived is not None: - db.set_session_archived(sid, body.archived) - if body.pinned is not None: - db.set_session_pinned(sid, body.pinned) - result = {"ok": True, "title": db.get_session_title(sid) or ""} - if body.archived is not None: - result["archived"] = bool(body.archived) - if body.pinned is not None: - result["pinned"] = bool(body.pinned) - return result - finally: - db.close() -@app.get("/api/sessions/{session_id}/export") -async def export_session_endpoint(session_id: str, profile: Optional[str] = None): - """Export a single session (metadata + messages) as JSON.""" - def _export(): - db = _open_session_db_for_profile(profile) - try: - sid = db.resolve_session_id(session_id) - return db.export_session(sid) if sid else None - finally: - db.close() - data = await asyncio.to_thread(_export) - if data is None: - raise HTTPException(status_code=404, detail="Session not found") - return data def _prune_sessions(body: SessionPrune): @@ -11761,10 +11156,6 @@ def _prune_sessions(body: SessionPrune): db.close() -@app.post("/api/sessions/prune") -async def prune_sessions_endpoint(body: SessionPrune): - """Delete ended sessions matching filters without blocking the event loop.""" - return await asyncio.to_thread(_prune_sessions, body) # --------------------------------------------------------------------------- @@ -12425,143 +11816,30 @@ def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: } -@app.get("/api/mcp/servers") -async def list_mcp_servers(profile: Optional[str] = None): - from hermes_cli.mcp_config import _get_mcp_servers +from hermes_cli.web_routers import mcp as _mcp_routes # noqa: E402 - with _profile_scope(profile): - servers = _get_mcp_servers() - return { - "servers": [ - _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items()) - ] - } +app.include_router(_mcp_routes.router) +from hermes_cli.web_routers.mcp import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + list_mcp_servers, + add_mcp_server, + replace_mcp_servers, + remove_mcp_server, + test_mcp_server, + auth_mcp_server, + mcp_oauth_flow_status, + mcp_oauth_callback, + set_mcp_server_enabled, + list_mcp_catalog, + install_mcp_catalog_entry, +) -@app.post("/api/mcp/servers") -async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None): - from hermes_cli.mcp_config import ( - _get_mcp_servers, - _save_bearer_auth_token, - _save_mcp_server, - ) - - try: - name, server_config, bearer_token = _normalize_mcp_server_create(body) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - - with _profile_scope(body.profile or profile): - existing = _get_mcp_servers() - if name in existing: - raise HTTPException(status_code=409, detail=f"Server '{name}' already exists") - - try: - with _profile_scope(body.profile or profile): - if bearer_token is not None: - server_config["headers"] = _save_bearer_auth_token(name, bearer_token) - if not _save_mcp_server(name, server_config): - raise HTTPException( - status_code=400, - detail=f"Server '{name}' rejected: suspicious command/args configuration", - ) - except HTTPException: - raise - except Exception as exc: - _log.exception("POST /api/mcp/servers failed") - raise HTTPException(status_code=400, detail=str(exc)) from exc - - return _mcp_server_summary(name, server_config) -@app.put("/api/mcp/servers") -async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None): - """Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save). - - The generic ``/api/config`` endpoint deep-merges maps, so it can never - delete a server key, drop an ``enabled: false`` flag, or remove a nested - field — edits looked saved but the stale entry survived on disk. This - endpoint sets the whole map so removals actually persist. Storage stays - the config.yaml ``mcp_servers`` key the CLI/TUI already read. - """ - from hermes_cli.mcp_config import _replace_mcp_servers - - with _profile_scope(body.profile or profile): - ok, issues = _replace_mcp_servers(body.servers) - if not ok: - raise HTTPException(status_code=400, detail="; ".join(issues)) - return {"ok": True} -@app.delete("/api/mcp/servers/{name}") -async def remove_mcp_server(name: str, profile: Optional[str] = None): - from hermes_cli.mcp_config import _remove_mcp_server - - with _profile_scope(profile): - removed = _remove_mcp_server(name) - if not removed: - raise HTTPException(status_code=404, detail=f"Server '{name}' not found") - return {"ok": True} -@app.post("/api/mcp/servers/{name}/test") -async def test_mcp_server(name: str, profile: Optional[str] = None): - """Connect to the server, list its tools, disconnect. Returns tool list.""" - from hermes_cli.mcp_config import ( - _get_mcp_servers, - _oauth_tokens_present, - _probe_single_server, - ) - - with _profile_scope(profile): - servers = _get_mcp_servers() - if name not in servers: - raise HTTPException(status_code=404, detail=f"Server '{name}' not found") - - details: Dict[str, Any] = {} - # An `auth: oauth` server that serves tools/list anonymously would probe OK - # with no token — a false green. Require a token on disk for it, matching the - # /auth verification (some providers don't enforce auth on tools/list). - needs_oauth_token = servers[name].get("auth") == "oauth" - - def _probe_scoped(): - # Home-only scope (contextvar), NOT _profile_scope. A probe blocks for - # as long as the server takes to spawn/connect — a stdio `npx` cold - # start is many seconds — and _profile_scope holds a process-global - # skills lock for its ENTIRE body. Holding that across the probe - # serialized every other endpoint (config/skills/toolsets all take the - # same lock), so a slow server made unrelated requests time out at 15s. - # The probe touches no skills globals; it only needs the HERMES_HOME - # override for .env interpolation + OAuth token resolution, which the - # contextvar provides (copied into this to_thread worker; and - # _run_on_mcp_loop re-wraps it onto the MCP event-loop thread). - with _config_profile_scope(profile): - tools = _probe_single_server(name, servers[name], details=details) - token_present = _oauth_tokens_present(name) if needs_oauth_token else True - return tools, token_present - - try: - # Probe blocks on a dedicated MCP event loop — run in a thread so the - # FastAPI event loop is never blocked. - tools, token_present = await asyncio.to_thread(_probe_scoped) - except Exception as exc: - return { - "ok": False, - "error": str(exc), - "tools": [], - } - if not token_present: - return { - "ok": False, - "error": "OAuth authentication required — no token found.", - "tools": [], - } - return { - "ok": True, - "tools": [{"name": t, "description": d} for t, d in tools], - "prompts": details.get("prompts", 0), - "resources": details.get("resources", 0), - } _MCP_DASHBOARD_OAUTH_TTL = 15 * 60 @@ -12696,288 +11974,16 @@ def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: flow.mark_worker_done() -@app.post("/api/mcp/servers/{name}/auth") -async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = None): - """Start MCP OAuth and hand the authorization URL to the dashboard browser.""" - from hermes_cli.mcp_config import _get_mcp_servers - from tools.mcp_dashboard_oauth import DashboardOAuthFlow - - _require_token(request) - _gc_mcp_oauth_flows() - from hermes_constants import get_hermes_home - - process_home = str(get_hermes_home().expanduser().resolve(strict=False)) - with _profile_scope(profile): - servers = _get_mcp_servers() - flow_home = str(get_hermes_home().expanduser().resolve(strict=False)) - if name not in servers: - raise HTTPException(status_code=404, detail=f"Server '{name}' not found") - cfg = dict(servers[name]) - if not cfg.get("url"): - raise HTTPException(status_code=400, detail="stdio servers authenticate via env keys, not OAuth") - if cfg.get("headers") and cfg.get("auth") != "oauth": - raise HTTPException(status_code=400, detail="This server uses header/API-key auth, not OAuth") - cfg["auth"] = "oauth" - - flow_id = secrets.token_urlsafe(24) - flow = DashboardOAuthFlow( - flow_id=flow_id, - server_name=name, - profile=profile, - hermes_home=flow_home, - redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri") - or _mcp_oauth_callback_url(request, name), - reconnect_live=flow_home == process_home, - ) - with _mcp_oauth_flows_lock: - pending = sum( - not flow.worker_done - for flow in _mcp_oauth_flows.values() - ) - if pending >= _MAX_PENDING_MCP_OAUTH_FLOWS: - raise HTTPException( - status_code=429, - detail="Too many MCP OAuth flows are already in progress", - ) - if any( - flow.server_name == name - and flow.hermes_home == flow_home - and not flow.worker_done - for flow in _mcp_oauth_flows.values() - ): - raise HTTPException( - status_code=409, - detail=f"MCP OAuth for '{name}' is already in progress", - ) - _mcp_oauth_flows[flow_id] = flow - threading.Thread( - target=_run_dashboard_mcp_oauth, - args=(flow, cfg), - daemon=True, - name=f"mcp-oauth-{name}", - ).start() - try: - await flow.wait_for_authorization_url(timeout=30) - except Exception as exc: - flow.mark_error(str(exc)) - return flow.snapshot() -@app.get("/api/mcp/oauth/flows/{flow_id}") -async def mcp_oauth_flow_status(flow_id: str, request: Request): - _require_token(request) - _gc_mcp_oauth_flows() - flow = _mcp_oauth_flows.get(flow_id) - if flow is None: - raise HTTPException(status_code=404, detail="OAuth flow not found or expired") - snapshot = flow.snapshot() - snapshot["tools"] = flow.tools - return snapshot -@app.get("/api/mcp/oauth/callback/{server_name:path}") -async def mcp_oauth_callback( - server_name: str, - code: Optional[str] = None, - state: Optional[str] = None, - error: Optional[str] = None, -): - _gc_mcp_oauth_flows() - with _mcp_oauth_flows_lock: - candidates = [ - flow - for flow in _mcp_oauth_flows.values() - if flow.server_name == server_name - and flow.status == "authorization_required" - ] - flow = next( - ( - candidate - for candidate in candidates - if candidate.expected_state is not None - and state is not None - and secrets.compare_digest(candidate.expected_state, state) - ), - None, - ) - if flow is None: - return HTMLResponse("

OAuth flow expired

Return to Hermes and try again.

", status_code=404) - try: - flow.deliver_callback(code=code, state=state, error=error) - except ValueError as exc: - reason = str(exc) - status_code = 409 if "already received" in reason else 400 - return HTMLResponse( - "

OAuth callback rejected

" - "

The callback was invalid or already used.

", - status_code=status_code, - ) - if error: - return HTMLResponse("

Authorization failed

Return to Hermes for details.

", status_code=400) - return HTMLResponse("

Authorization received

You can close this tab and return to Hermes.

") -@app.put("/api/mcp/servers/{name}/enabled") -async def set_mcp_server_enabled( - name: str, body: MCPEnabledToggle, profile: Optional[str] = None -): - """Enable or disable an MCP server (takes effect on next session/gateway). - - Toggles the ``enabled`` key on the server's config.yaml entry — the same - flag the agent reads at startup. Disabled servers stay in config so they - can be re-enabled without re-entering their settings. - """ - with _profile_scope(body.profile or profile): - cfg = load_config() - servers = cfg.get("mcp_servers") - if not isinstance(servers, dict) or name not in servers: - raise HTTPException(status_code=404, detail=f"Server '{name}' not found") - if not isinstance(servers[name], dict): - raise HTTPException(status_code=400, detail="Malformed server config") - servers[name]["enabled"] = bool(body.enabled) - save_config(cfg) - return {"ok": True, "name": name, "enabled": bool(body.enabled)} -@app.get("/api/mcp/catalog") -async def list_mcp_catalog(profile: Optional[str] = None): - """Browse the Nous-approved MCP catalog (the optional-mcps/ manifests). - - Each entry reports whether it's already installed and enabled so the UI - can show install / enabled state inline. This is the same catalog - `hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes - the installed/enabled annotations (the catalog itself is repo-shipped - and identical for every profile). - """ - try: - from hermes_cli import mcp_catalog - except Exception as exc: - _log.exception("mcp_catalog import failed") - raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}") - - entries = [] - try: - with _profile_scope(profile): - catalog_entries = list(mcp_catalog.list_catalog()) - installed_state = { - e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name)) - for e in catalog_entries - } - for entry in catalog_entries: - auth = entry.auth - transport = entry.transport - install = entry.install - entries.append({ - "name": entry.name, - "description": entry.description, - "source": entry.source, - "transport": transport.type, - "auth_type": getattr(auth, "type", "none"), - # Env vars the user must supply (names + prompts only, never values). - "required_env": [ - {"name": e.name, "prompt": e.prompt, "required": e.required} - for e in getattr(auth, "env", []) or [] - ], - # Transport details so the UI can show exactly what connects/runs. - # The trust model (docs: user-guide/features/mcp) tells users to - # inspect command/args/url and the install bootstrap before - # installing — surface them rather than hiding them in the repo. - "command": transport.command, - "args": list(transport.args or []), - "url": transport.url, - # Git bootstrap (present only for entries that clone + build). - "install_url": install.url if install else None, - "install_ref": install.ref if install else None, - "bootstrap": list(install.bootstrap) if install else [], - # Default tool pre-selection hint and post-install guidance. - "default_enabled": list(entry.tools.default_enabled) - if entry.tools.default_enabled is not None - else None, - "post_install": entry.post_install or "", - "needs_install": entry.install is not None, - "installed": installed_state.get(entry.name, (False, False))[0], - "enabled": installed_state.get(entry.name, (False, False))[1], - }) - except HTTPException: - # Unknown/invalid profile → 404, not a silently-empty catalog. - raise - except Exception: - _log.exception("list_mcp_catalog failed") - - diagnostics = [] - try: - diagnostics = [ - {"name": n, "kind": k, "message": m} - for (n, k, m) in mcp_catalog.catalog_diagnostics() - ] - except Exception: - pass - - return {"entries": entries, "diagnostics": diagnostics} -@app.post("/api/mcp/catalog/install") -async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None): - """Install a catalog MCP into config.yaml. - - For HTTP/stdio entries with required env vars, those are written to .env - via the standard env path so the agent can read them at session start. - Entries that need a git bootstrap (``needs_install``) are installed via - the CLI action path because the clone can take time. - """ - from hermes_cli import mcp_catalog - - name = (body.name or "").strip() - entry = mcp_catalog.get_entry(name) - if entry is None: - raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'") - - # Persist any supplied env vars first (catalog entries declare which names - # they need; we only write the ones the user provided). - effective_profile = body.profile or profile - if body.env: - with _profile_scope(effective_profile): - for k, v in body.env.items(): - if v: - save_env_value(k, v) - - # Git-bootstrap entries can take a while to clone — run via the background - # action path so the request returns immediately and the UI can tail logs. - # The -p subprocess rebinds HERMES_HOME-derived paths in the child. - if entry.install is not None: - # Unique per-entry action name: a shared "mcp-install" would let a - # re-click (or a second entry) overwrite the tracked process/log while - # the first clone is still running. - action = _mcp_install_action_name(name) - try: - _spawn_hermes_action( - _profile_cli_args(effective_profile) + ["mcp", "install", name], - action, - ) - except HTTPException: - raise - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Install failed: {exc}") - return {"ok": True, "name": name, "background": True, "action": action} - - # No git step — install synchronously via the catalog API. install_entry - # routes through load_config/save_config + save_env_value, all call-time - # resolvers, so the context override scopes it. Wrap the to_thread body - # in the scope INSIDE the thread (contextvars don't propagate into - # to_thread the other way around — asyncio.to_thread copies context, so - # setting it here works; keep it explicit for clarity). - def _install_scoped(): - with _profile_scope(effective_profile): - mcp_catalog.install_entry(entry, enable=body.enable) - - try: - await asyncio.to_thread(_install_scoped) - except HTTPException: - raise - except Exception as exc: - _log.exception("install_mcp_catalog_entry failed") - raise HTTPException(status_code=400, detail=str(exc)) - return {"ok": True, "name": name, "background": False} def _mcp_install_action_name(name: str) -> str: @@ -13920,60 +12926,22 @@ def _hub_action_name(verb: str, key: str) -> str: return name -@app.post("/api/skills/hub/install") -async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None): - identifier = (body.identifier or "").strip() - if not identifier: - raise HTTPException(status_code=400, detail="identifier is required") - name = _hub_action_name("install", identifier) - try: - proc = _spawn_hermes_action( - _profile_cli_args(body.profile or profile) - + ["skills", "install", identifier, "--yes"], - name, - ) - 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}") - return {"ok": True, "pid": proc.pid, "name": name} +from hermes_cli.web_routers import skills as _skills_routes # noqa: E402 + +app.include_router(_skills_routes.hub_router) +from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + install_skill_hub, + uninstall_skill_hub, + update_skills_hub, + list_skills_hub_sources, + search_skills_hub, + preview_skill_hub, + scan_skill_hub, +) -@app.post("/api/skills/hub/uninstall") -async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None): - name = (body.name or "").strip() - if not name: - raise HTTPException(status_code=400, detail="name is required") - action = _hub_action_name("uninstall", name) - try: - proc = _spawn_hermes_action( - _profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"], - action, - ) - 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": action} -@app.post("/api/skills/hub/update") -async def update_skills_hub( - body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None -): - try: - effective = (body.profile if body else None) or profile - proc = _spawn_hermes_action( - _profile_cli_args(effective) + ["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}") - return {"ok": True, "pid": proc.pid, "name": "skills-update"} # Human-readable labels for each hub source id (matches `hermes skills search` @@ -14034,288 +13002,12 @@ def _installed_hub_identifiers(profile: Optional[str] = None) -> dict: return {} -@app.get("/api/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(): - from tools.skills_hub import create_source_router - - with _config_profile_scope(profile): - sources = create_source_router() - out = [] - index_available = False - featured = [] - for src in sources: - sid = src.source_id() - entry = { - "id": sid, - "label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid), - } - # GitHub exposes a rate-limit flag; the index an availability flag. - if sid == "github": - try: - entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False)) - except Exception: - entry["rate_limited"] = False - if sid == "hermes-index": - try: - index_available = bool(getattr(src, "is_available", False)) - except Exception: - index_available = False - entry["available"] = index_available - # Empty-query search on the index returns featured/popular skills. - if index_available: - try: - featured = [ - _skill_meta_to_payload(m) for m in src.search("", limit=12) - ] - except Exception: - featured = [] - out.append(entry) - # Tell the UI which sources are worth searching individually (for its - # progressive per-source fan-out). Mirror parallel_search_sources: when - # the centralized index is available it already subsumes the external - # API sources, so they're redundant — skipping them avoids ~70 GitHub - # calls per keystroke. Keep this set in sync with that function's - # ``_api_source_ids``. - _api_source_ids = frozenset( - {"github", "skills-sh", "clawhub", "lobehub", "well-known"} - ) - for entry in out: - entry["searchable"] = not (index_available and entry["id"] in _api_source_ids) - return { - "sources": out, - "index_available": index_available, - "featured": featured, - "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, profile: Optional[str] = None -): - """Search the skill hub across all configured sources. - - Network-bound (parallel source search); runs in a thread so the FastAPI - loop isn't blocked. Returns structured results the UI installs by - identifier via POST /api/skills/hub/install, previews via - /api/skills/hub/preview, and scans via /api/skills/hub/scan. - """ - query = (q or "").strip() - if not query: - return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}} - - def _run(): - from tools.skills_hub import create_source_router, parallel_search_sources - - with _config_profile_scope(profile): - sources = create_source_router() - capped = min(max(limit, 1), 50) - all_results, source_counts, timed_out = parallel_search_sources( - sources, query=query, source_filter=source or "all", overall_timeout=30 - ) - - # Dedupe by identifier, preferring higher trust (mirrors unified_search). - _rank = {"builtin": 2, "trusted": 1, "community": 0} - seen = {} - for r in all_results: - if r.identifier not in seen: - seen[r.identifier] = r - elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0): - seen[r.identifier] = r - deduped = list(seen.values())[:capped] - - return { - "results": [_skill_meta_to_payload(m) for m in deduped], - "source_counts": source_counts, - "timed_out": timed_out, - "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}") -@app.get("/api/skills/hub/preview") -async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None): - """Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading. - - Resolves the identifier across configured sources (same path the CLI - installer uses), then returns the rendered SKILL.md text and the file - manifest WITHOUT installing anything. This is the 'read the actual skill - before installing' affordance the Browse-hub tab was missing. - - Scoped to ``profile`` so a non-default profile with different hub taps - resolves against ITS source router, not the default profile's. - """ - ident = (identifier or "").strip() - if not ident: - raise HTTPException(status_code=400, detail="identifier is required") - - def _run(): - from hermes_cli.skills_hub import _resolve_source_meta_and_bundle - from tools.skills_hub import create_source_router - - with _config_profile_scope(profile): - sources = create_source_router() - meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources) - if not bundle and not meta: - return None - - files = {} - skill_md = "" - if bundle: - for rel, content in (bundle.files or {}).items(): - if isinstance(content, bytes): - # Some sources (e.g. official optional skills) store every - # file as bytes. Decode text so SKILL.md / docs render; - # only fall back to a placeholder for genuinely-binary data. - try: - files[rel] = content.decode("utf-8") - except UnicodeDecodeError: - files[rel] = "(binary file)" - else: - files[rel] = content - skill_md = files.get("SKILL.md", "") or "" - - m = meta or bundle - return { - "name": getattr(m, "name", ident), - "description": getattr(m, "description", "") or "", - "source": getattr(m, "source", "") or "", - "identifier": getattr(m, "identifier", ident) or ident, - "trust_level": getattr(m, "trust_level", "community") or "community", - "repo": getattr(m, "repo", None), - "tags": list(getattr(m, "tags", None) or []), - "skill_md": skill_md, - "files": sorted(files.keys()), - } - - try: - result = await asyncio.to_thread(_run) - except Exception as exc: - _log.exception("skills hub preview failed") - raise HTTPException(status_code=502, detail=f"Hub preview failed: {exc}") - if result is None: - raise HTTPException(status_code=404, detail=f"Skill not found: {ident}") - return result -@app.get("/api/skills/hub/scan") -async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None): - """Run the install-time security scan on a hub skill WITHOUT installing it. - - Fetches the bundle, quarantines it, and runs the same `scan_skill` / - `should_allow_install` pipeline the CLI installer uses — then cleans up the - quarantine. Returns the verdict, per-finding detail, trust tier, and the - install-policy decision so the dashboard can show a visual safety result - on demand (the 'scan' button the Browse-hub tab was missing). - - Scoped to ``profile`` so the bundle resolves against that profile's hub - source router, matching where an install would pull it from. - """ - ident = (identifier or "").strip() - if not ident: - raise HTTPException(status_code=400, detail="identifier is required") - - def _run(): - import shutil as _shutil - - from hermes_cli.skills_hub import _resolve_source_meta_and_bundle - from tools.skills_hub import create_source_router, quarantine_bundle - from tools.skills_guard import scan_skill, should_allow_install - - with _config_profile_scope(profile): - sources = create_source_router() - meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources) - if not bundle: - return None - - if bundle.source == "official": - scan_source = "official" - else: - scan_source = ( - getattr(bundle, "identifier", "") - or getattr(meta, "identifier", "") - or ident - ) - - q_path = None - try: - q_path = quarantine_bundle(bundle) - result = scan_skill(q_path, source=scan_source) - finally: - if q_path is not None: - _shutil.rmtree(q_path, ignore_errors=True) - - allowed, reason = should_allow_install(result, force=False) - # `allowed` may be None ("ask") for agent-created/dangerous gates. - if allowed is True: - policy = "allow" - elif allowed is None: - policy = "ask" - else: - policy = "block" - - findings = [ - { - "severity": f.severity, - "category": f.category, - "file": f.file, - "line": f.line, - "description": f.description, - } - for f in result.findings - ] - # Per-severity tally for an at-a-glance summary. - counts = {"critical": 0, "high": 0, "medium": 0, "low": 0} - for f in result.findings: - if f.severity in counts: - counts[f.severity] += 1 - - return { - "name": result.skill_name, - "identifier": ident, - "source": result.source, - "trust_level": result.trust_level, - "verdict": result.verdict, - "summary": result.summary, - "policy": policy, - "policy_reason": reason, - "findings": findings, - "severity_counts": counts, - } - - try: - result = await asyncio.to_thread(_run) - except Exception as exc: - _log.exception("skills hub scan failed") - raise HTTPException(status_code=502, detail=f"Hub scan failed: {exc}") - if result is None: - raise HTTPException(status_code=404, detail=f"Skill not found: {ident}") - return result # --------------------------------------------------------------------------- @@ -14676,50 +13368,16 @@ def _config_profile_scope(profile: Optional[str]): reset_hermes_home_override(token) -@app.get("/api/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 - from tools.skill_usage import ( - _read_bundled_manifest_names, - _read_hub_installed_names, - activity_count, - load_usage, - ) - with _profile_scope(profile): - config = load_config() - disabled = get_disabled_skills(config) - skills = _find_all_skills(skip_disabled=True) - usage = load_usage() - # Set-based provenance (same classification as skill_usage.provenance, - # without a per-skill manifest read): hub > bundled > agent, where - # "agent" covers agent-authored AND local hand-made skills — the ones - # the user may edit/delete from the UI. - bundled_names = _read_bundled_manifest_names() - hub_names = _read_hub_installed_names() - for s in skills: - s["enabled"] = s["name"] not in disabled - s["usage"] = activity_count(usage.get(s["name"], {})) - s["provenance"] = ( - "hub" if s["name"] in hub_names - else "bundled" if s["name"] in bundled_names - else "agent" - ) - return skills +app.include_router(_skills_routes.router) +from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + get_skills, + toggle_skill, + get_skill_content, + create_skill, + update_skill_content, +) -@app.put("/api/skills/toggle") -async def toggle_skill(body: SkillToggle, profile: Optional[str] = None): - from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills - with _profile_scope(body.profile or 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} def _clear_skills_prompt_cache() -> None: @@ -14735,289 +13393,33 @@ def _clear_skills_prompt_cache() -> None: pass -@app.get("/api/skills/content") -async def get_skill_content(name: str, profile: Optional[str] = None): - """Return the raw SKILL.md text for a skill, for the dashboard editor.""" - from tools.skill_manager_tool import _find_skill - - with _profile_scope(profile): - found = _find_skill(name) - if not found: - raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.") - skill_md = found["path"] / "SKILL.md" - if not skill_md.exists(): - raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.") - try: - content = skill_md.read_text(encoding="utf-8") - except OSError as exc: - raise HTTPException(status_code=500, detail=str(exc)) from exc - return {"name": name, "content": content, "path": str(skill_md)} -@app.post("/api/skills") -async def create_skill(body: SkillCreate): - """Create a new custom skill (SKILL.md) from the dashboard editor. - - Calls the same validated write path as the agent's ``skill_manage`` - tool (frontmatter validation, name/category validation, size limit, - optional security scan) — but bypasses the agent write-approval gate: - a write from the authenticated dashboard IS the user acting directly. - """ - from tools.skill_manager_tool import _create_skill - - with _profile_scope(body.profile): - result = _create_skill(body.name, body.content, body.category or None) - if not result.get("success"): - raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill.")) - _clear_skills_prompt_cache() - return result -@app.put("/api/skills/content") -async def update_skill_content(body: SkillContentUpdate): - """Replace the SKILL.md of an existing skill (full rewrite) from the editor.""" - from tools.skill_manager_tool import _edit_skill - - with _profile_scope(body.profile): - result = _edit_skill(body.name, body.content) - if not result.get("success"): - err = result.get("error", "Failed to update skill.") - status = 404 if "not found" in str(err).lower() else 400 - raise HTTPException(status_code=status, detail=err) - _clear_skills_prompt_cache() - return result -@app.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, - gui_toolset_label, - ) - from hermes_cli.platforms import platform_label - from toolsets import resolve_toolset +from hermes_cli.web_routers import tools as _tools_routes # noqa: E402 - 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 - } - 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). - from utils import is_truthy_value - - 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), - "tools": tools, - }) - return result +app.include_router(_tools_routes.router) +from hermes_cli.web_routers.tools import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. + get_toolsets, + toggle_toolset, + get_toolset_config, + get_toolset_models, + select_toolset_model, + select_toolset_provider, + save_toolset_env, + run_toolset_post_setup, + get_terminal_backends, + select_terminal_backend, + get_computer_use_status, + grant_computer_use_permissions, +) -@app.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. - - 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 ( - _CONFIG_ONLY_TOOLSETS, - _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) - if name in _CONFIG_ONLY_TOOLSETS: - # Config-only capabilities (stt) toggle their own config section's - # ``enabled`` flag — there is no platform_toolsets entry to write. - with _profile_scope(body.profile or profile): - config = load_config() - section = config.setdefault(name, {}) - if not isinstance(section, dict): - section = {} - config[name] = section - section["enabled"] = bool(body.enabled) - save_config(config) - return { - "ok": True, - "name": name, - "platform": target_platform, - "enabled": body.enabled, - } - with _profile_scope(body.profile or profile): - config = load_config() - 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) - return { - "ok": True, - "name": name, - "platform": target_platform, - "enabled": body.enabled, - } -@app.get("/api/tools/toolsets/{name}/config") -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 - (via ``_visible_providers``), each with its ``env_vars`` annotated with - current ``is_set`` state so the GUI can render provider selection + key - entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty - provider list and ``has_category: false``. Returns 400 for unknown keys. - """ - from hermes_cli.tools_config import ( - TOOL_CATEGORIES, - _get_effective_configurable_toolsets, - _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 - - 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}") - - with _profile_scope(profile): - config = load_config() - cat = TOOL_CATEGORIES.get(name) - providers = [] - active_provider = None - active_search_backend = None - active_extract_backend = None - if cat: - # Fetch portal/entitlement state once for the whole matrix — the - # per-provider readiness computation below reuses it instead of - # re-probing per row. - 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", []) - ] - # 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"] - 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, - # Honest server-side readiness. The GUI's old client-side - # heuristic showed "Ready" for every zero-env-var row — - # including logged-out Nous Subscription rows and never-run - # post_setup installs (see provider_readiness_status). - "status": provider_readiness_status( - prov, config, features=features, is_active=is_active - ), - } - if name == "web" and prov.get("web_backend"): - # The runtime split web into two capabilities long ago - # (web.search_backend / web.extract_backend); surface each - # row's backend key and which capabilities it can serve so - # the GUI can offer 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"): - # The provider key written to tts.provider on selection. - # Doubles as the config section holding the provider's - # voice/model settings (tts..*) so the GUI can render - # those fields inline in the Capabilities panel. - row["tts_provider"] = prov["tts_provider"] - providers.append(row) - if name == "web": - # Resolve the per-capability active backends exactly the way the - # web_search / web_extract dispatchers do (per-capability key → - # shared web.backend → credential auto-detect), so the GUI badges - # reflect what a tool call would actually hit right now. - try: - from tools.web_tools import _get_extract_backend, _get_search_backend - - active_search_backend = _get_search_backend() - active_extract_backend = _get_extract_backend() - except Exception: - active_search_backend = None - active_extract_backend = None - payload = { - "name": name, - "has_category": cat is not None, - "providers": providers, - "active_provider": active_provider, - } - if name == "web": - payload["active_search_backend"] = active_search_backend - payload["active_extract_backend"] = active_extract_backend - return payload # Toolsets whose backends carry a selectable model catalog, mapped to the @@ -15077,349 +13479,14 @@ def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str ) -@app.get("/api/tools/toolsets/{name}/models") -async def get_toolset_models( - name: str, provider: Optional[str] = None, profile: Optional[str] = None -): - """Return the model catalog for a toolset backend (image/video gen). - - The GUI counterpart of the model picker `hermes tools` runs after a - backend is selected — e.g. FAL's multi-model catalog (speed / strengths / - price per model). ``provider`` names a picker row; omitted, the currently - active provider is used. Toolsets without model catalogs return - ``has_models: false``. - """ - section = _MODEL_CATALOG_TOOLSETS.get(name) - if section is None: - return {"name": name, "has_models": False, "models": [], "current": None, "default": None} - - 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 { - "name": name, - "has_models": False, - "models": [], - "current": None, - "default": 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") if row else None, - "plugin": plugin, - "models": models, - "current": current, - "default": default_model, - } -@app.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``). - - Validates the model against the resolved backend's catalog — the same - write the CLI's post-selection model picker performs. Returns 400 for - toolsets without model catalogs or unknown model ids. - """ - section = _MODEL_CATALOG_TOOLSETS.get(name) - if section is None: - raise HTTPException( - status_code=400, detail=f"Toolset has no model catalog: {name}" - ) - - model_id = (body.model or "").strip() - if not model_id: - raise HTTPException(status_code=400, detail="model is required") - - with _profile_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 HTTPException( - status_code=400, - detail=f"No model-capable backend is active for {name}", - ) - - catalog, _default = _toolset_model_catalog(name, plugin) - if model_id not in catalog: - raise HTTPException( - status_code=400, - detail=f"Unknown model {model_id!r} for backend {plugin!r}", - ) - - section_cfg = config.setdefault(section, {}) - if not isinstance(section_cfg, dict): - section_cfg = {} - config[section] = section_cfg - section_cfg["model"] = model_id - save_config(config) - - return {"ok": True, "name": name, "model": model_id, "plugin": plugin} -@app.put("/api/tools/toolsets/{name}/provider") -async def select_toolset_provider( - name: str, body: ToolsetProviderSelect, profile: Optional[str] = None -): - """Persist a provider selection for a toolset (no key prompting). - - Delegates to ``apply_provider_selection`` — the shared, non-interactive - core extracted from the CLI configurator — so the GUI and ``hermes tools`` - write identical config keys (``web.backend``, ``tts.provider``, etc.). - API keys and post-setup flows are handled by separate endpoints. Returns - 400 for unknown toolset or provider names. - - For the ``web`` toolset only, an optional ``capability`` ('search' | - 'extract') scopes the selection to ``web.search_backend`` / - ``web.extract_backend`` — the same per-capability overrides the runtime - dispatchers (``tools.web_tools._get_search_backend`` / - ``_get_extract_backend``) resolve first. The provider must actually - support the requested capability (a search-only backend can't be the - extract backend). Omitting ``capability`` keeps the legacy whole-provider - behavior (writes ``web.backend``). - - Managed Nous rows (``managed_nous_feature``) additionally report the - Portal entitlement state: the CLI flow gates these selections on - ``ensure_nous_portal_access`` (inline login), but the GUI has no inline - prompt, so selecting one while logged out / unentitled used to write the - config keys and then never activate (``_is_provider_active`` requires - ``managed_by_nous``). The response now carries an additive - ``needs_nous_auth: true`` + ``feature`` so the client can drive the - existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``) - and refetch. - """ - from hermes_cli.tools_config import ( - TOOL_CATEGORIES, - apply_provider_selection, - web_provider_capabilities, - _get_effective_configurable_toolsets, - _visible_providers, - ) - from hermes_cli.nous_subscription import ( - MANAGED_FEATURE_COVERAGE_CATEGORY, - get_nous_subscription_features, - ) - - 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}") - - if body.capability is not None: - if name != "web": - raise HTTPException( - status_code=400, - detail="capability selection is only supported for the web toolset", - ) - if body.capability not in ("search", "extract"): - raise HTTPException( - status_code=400, - detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')", - ) - - with _profile_scope(body.profile or profile): - config = load_config() - if body.capability is not None: - # Per-capability path: resolve the picker row to its backend key - # and write web._backend. Does NOT touch web.backend, - # so the other capability keeps resolving through the shared - # fallback chain. - cat = TOOL_CATEGORIES.get(name) - providers = _visible_providers(cat, config, force_fresh=True) if cat else [] - prov = next((p for p in providers if p.get("name") == body.provider), None) - if prov is None: - raise HTTPException( - status_code=400, - detail=f"Unknown provider {body.provider!r} for toolset {name!r}", - ) - backend = prov.get("web_backend") - if not backend: - raise HTTPException( - status_code=400, - detail=f"Provider {body.provider!r} has no web backend key", - ) - if body.capability not in web_provider_capabilities(backend): - raise HTTPException( - status_code=400, - detail=f"{body.provider} does not support {body.capability}", - ) - web_cfg = config.setdefault("web", {}) - if not isinstance(web_cfg, dict): - web_cfg = {} - config["web"] = web_cfg - web_cfg[f"{body.capability}_backend"] = backend - else: - try: - apply_provider_selection(name, body.provider, config) - except KeyError as exc: - raise HTTPException(status_code=400, detail=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 gate the CLI - # applies via ensure_nous_portal_access at selection time. - cat = TOOL_CATEGORIES.get(name) - row = None - if cat: - row = next( - ( - p - for p in _visible_providers(cat, config, force_fresh=True) - if p.get("name") == body.provider - ), - None, - ) - 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 -@app.put("/api/tools/toolsets/{name}/env") -async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None): - """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}") - - with _profile_scope(body.profile or 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))}", - ) - - 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} -@app.post("/api/tools/toolsets/{name}/post-setup") -async def run_toolset_post_setup( - name: str, body: ToolsetPostSetup, profile: Optional[str] = None -): - """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. - - ``profile`` spawns the hook as ``hermes -p tools post-setup``. - Most hooks install machine-level artifacts (repo node_modules, shared - pip packages) where the scope is inert, but hooks that read config or - write per-profile state must see the same HERMES_HOME the rest of the - drawer's writes targeted — so the scope is threaded for consistency. - """ - 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( - _profile_cli_args(body.profile or profile) - + ["tools", "post-setup", body.key], - "tools-post-setup", - ) - except HTTPException: - raise - 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} # --------------------------------------------------------------------------- @@ -15587,66 +13654,8 @@ def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: return ("unavailable", f"Probe failed: {exc}") -@app.get("/api/tools/terminal/backends") -async def get_terminal_backends(profile: Optional[str] = None): - """Terminal execution backend rows with health probes for the picker panel. - - Returns ``{active, backends: [{name, label, description, active, status, - detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` / - ``unavailable`` and ``detail`` carries setup guidance for non-ready rows. - Probes are fast (<~2s each) and defensive — a probe failure surfaces as a - status, never an error response. - """ - with _profile_scope(profile): - config = load_config() - terminal_cfg = config.get("terminal") - if not isinstance(terminal_cfg, dict): - terminal_cfg = {} - active = str(terminal_cfg.get("backend") or "local").strip().lower() - if active not in _TERMINAL_BACKEND_NAMES: - active = "local" - - backends = [] - for row in _TERMINAL_BACKENDS: - 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} -@app.put("/api/tools/terminal/backend") -async def select_terminal_backend( - body: TerminalBackendSelect, profile: Optional[str] = None -): - """Persist ``terminal.backend`` in config.yaml. - - Validates against the known backend set (the same enum the raw-config - settings row exposes). Selecting a backend that still needs setup is - allowed — the picker shows guidance instead of blocking, matching the CLI. - """ - backend = (body.backend or "").strip().lower() - if backend not in _TERMINAL_BACKEND_NAMES: - raise HTTPException( - status_code=400, - detail=f"Unknown terminal backend: {body.backend!r}. " - f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}", - ) - - with _profile_scope(body.profile or profile): - config = load_config() - terminal_cfg = config.setdefault("terminal", {}) - if not isinstance(terminal_cfg, dict): - terminal_cfg = {} - config["terminal"] = terminal_cfg - terminal_cfg["backend"] = backend - save_config(config) - return {"ok": True, "backend": backend} # --------------------------------------------------------------------------- @@ -15661,49 +13670,8 @@ async def select_terminal_backend( # --------------------------------------------------------------------------- -@app.get("/api/tools/computer-use/status") -async def get_computer_use_status(profile: Optional[str] = None): - """Cross-platform Computer Use readiness for the desktop card. - - See ``tools.computer_use.permissions.computer_use_status`` for the payload - shape. Read-only and fast (shells ``cua-driver doctor`` + macOS - ``permissions status``). - """ - from tools.computer_use.permissions import computer_use_status - - with _profile_scope(profile): - return computer_use_status() -@app.post("/api/tools/computer-use/permissions/grant") -async def grant_computer_use_permissions(profile: Optional[str] = None): - """Spawn ``hermes computer-use permissions grant`` as a background action. - - macOS-only: ``cua-driver permissions grant`` launches CuaDriver via - LaunchServices so the TCC dialog is attributed to com.trycua.driver, then - waits for approval. The frontend polls ``GET /api/actions/computer-use- - grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have - no TCC toggles to grant, so this returns 400 there. - """ - if sys.platform != "darwin": - raise HTTPException( - status_code=400, - detail="Computer Use permission grants are a macOS concept.", - ) - try: - proc = _spawn_hermes_action( - _profile_cli_args(profile) - + ["computer-use", "permissions", "grant"], - "computer-use-grant", - ) - except HTTPException: - raise - except Exception as exc: - _log.exception("Failed to spawn computer-use permissions grant") - raise HTTPException( - status_code=500, detail=f"Failed to request permissions: {exc}" - ) - return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"} # --------------------------------------------------------------------------- diff --git a/tests/test_web_server_sessiondb_eventloop.py b/tests/test_web_server_sessiondb_eventloop.py index 7b91af5a888..7f014471c7c 100644 --- a/tests/test_web_server_sessiondb_eventloop.py +++ b/tests/test_web_server_sessiondb_eventloop.py @@ -4,6 +4,7 @@ import threading from pathlib import Path from hermes_cli import web_server +from hermes_cli.web_routers import sessions as web_sessions TARGET_HANDLERS = { @@ -29,15 +30,18 @@ def _call_name(call: ast.Call) -> str | None: def test_sessiondb_handlers_open_connections_inside_executor_helpers(): - tree = ast.parse(Path(web_server.__file__).read_text(encoding="utf-8")) - handlers = { - node.name: node - for node in tree.body - if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS - } - top_level_helpers = { - node.name: node for node in tree.body if isinstance(node, ast.FunctionDef) - } + # The session route handlers were extracted to web_routers/sessions.py + # (wave 2); the analytics handlers and the executor helpers still live in + # web_server.py — scan both modules' top-level bodies. + handlers: dict[str, ast.AsyncFunctionDef] = {} + top_level_helpers: dict[str, ast.FunctionDef] = {} + for mod in (web_server, web_sessions): + tree = ast.parse(Path(mod.__file__).read_text(encoding="utf-8")) + for node in tree.body: + if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS: + handlers[node.name] = node + elif isinstance(node, ast.FunctionDef): + top_level_helpers[node.name] = node assert handlers.keys() == TARGET_HANDLERS for name, handler in handlers.items():