refactor(hermes_cli): group F — drop section banners, one-line UI helpers, fold configure pre-selection, hug closers

This commit is contained in:
Teknium
2026-09-02 20:50:08 -07:00
parent ff5d0c4312
commit df0688c9cc
8 changed files with 138 additions and 219 deletions
+34 -60
View File
@@ -27,9 +27,6 @@ _MANIFEST_VERSION = 1
_INSTALL_DIR_VAR = "${INSTALL_DIR}"
# ─── Data classes ────────────────────────────────────────────────────────────
@dataclass
class EnvVarSpec:
name: str
@@ -43,8 +40,7 @@ class EnvVarSpec:
class AuthSpec:
type: str # "api_key" | "oauth" | "none"
env: List[EnvVarSpec] = field(default_factory=list)
# OAuth-specific (third-party provider like Google)
provider: Optional[str] = None
provider: Optional[str] = None # OAuth-specific (third-party provider like Google)
scopes: List[str] = field(default_factory=list)
env_var: Optional[str] = None
@@ -56,8 +52,8 @@ class TransportSpec:
args: List[str] = field(default_factory=list)
url: Optional[str] = None
version: Optional[str] = None # informational, pinned
# Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets —
# credentials go through auth.env so they are prompted for and land in ~/.hermes/.env.
# Static env for the stdio subprocess (telemetry opt-outs, mode flags). NOT for secrets — those
# go through auth.env so they are prompted for and land in ~/.hermes/.env.
env: Dict[str, str] = field(default_factory=dict)
@@ -72,15 +68,15 @@ class InstallSpec:
@dataclass
class ToolsSpec:
"""Manifest-side tool-selection hints: pre-check state for the install checklist and the
fallback selection when the probe fails (see install_entry())."""
"""Manifest-side tool-selection hints (see _apply_tool_selection()).
``default_enabled``: pre-checked in the install checklist / applied directly on probe failure;
None => all pre-checked (no filter written on failure). ``default_excluded``: exclude-mode
counterpart written to ``tools.exclude`` — everything NOT matching stays enabled, including tools
the server adds later (for huge OpenAPI-derived surfaces). Mutually exclusive.
"""
# Pre-checked (or applied directly on probe failure). None => all probed tools pre-checked
# (or no filter written on probe failure).
default_enabled: Optional[List[str]] = None
# Exclude-mode counterpart written to ``tools.exclude``: everything NOT matching stays enabled,
# including tools the server adds later. For huge auto-generated surfaces (OpenAPI-derived
# MCPs). Mutually exclusive with ``default_enabled``.
default_excluded: Optional[List[str]] = None
@@ -110,9 +106,6 @@ class CatalogEntry:
manifest_path: Path = field(default_factory=Path)
# ─── Manifest loader ─────────────────────────────────────────────────────────
class CatalogError(Exception):
"""Manifest parse/validation failure or install error."""
@@ -130,8 +123,7 @@ def _parse_env_spec(raw: Any) -> EnvVarSpec:
raise CatalogError(f"invalid env var name: {name!r}")
return EnvVarSpec(
name=name, prompt=raw.get("prompt") or name, required=bool(raw.get("required", True)),
secret=bool(raw.get("secret", True)), default=str(raw.get("default") or ""),
)
secret=bool(raw.get("secret", True)), default=str(raw.get("default") or ""))
def _require_mapping(path: Path, key: str, raw: Any) -> dict:
@@ -160,12 +152,13 @@ def _parse_transport(path: Path, raw: Any) -> TransportSpec:
raise CatalogError(f"{path}: transport.type must be 'stdio' or 'http'")
args = _require_list(path, "transport.args", transport_raw.get("args") or [])
env_raw = transport_raw.get("env") or {}
if not isinstance(env_raw, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()):
if not isinstance(env_raw, dict) or not all(
isinstance(k, str) and isinstance(v, str) for k, v in env_raw.items()
):
raise CatalogError(f"{path}: transport.env must be a mapping of string to string")
transport = TransportSpec(
type=t_type, command=transport_raw.get("command"), args=[str(a) for a in args],
url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw),
)
url=transport_raw.get("url"), version=transport_raw.get("version"), env=dict(env_raw))
if t_type == "stdio" and not transport.command:
raise CatalogError(f"{path}: stdio transport requires 'command'")
if t_type == "http" and not transport.url:
@@ -189,12 +182,10 @@ def _parse_auth(path: Path, raw: Any, name: str, http: bool) -> AuthSpec:
if all(spec.name != _required_key for spec in env_list):
raise CatalogError(
f"{path}: http + api_key auth requires auth.env to declare "
f"'{_required_key}' (the key the Authorization header references)"
)
f"'{_required_key}' (the key the Authorization header references)")
return AuthSpec(
type=a_type, env=env_list, provider=auth_raw.get("provider"),
scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"),
)
scopes=list(auth_raw.get("scopes") or []), env_var=auth_raw.get("env_var"))
def _parse_tools(path: Path, raw: Any) -> ToolsSpec:
@@ -222,8 +213,7 @@ def _parse_suggest(path: Path, suggest_raw: Any) -> Optional[SuggestSpec]:
# Matching is case-insensitive whole-word / host-suffix: store lowercase so UIs needn't re-normalize.
return SuggestSpec(
keywords=[k.strip().lower() for k in kw_raw],
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw],
)
hosts=[h.strip().lower().lstrip(".") for h in hosts_raw])
def _parse_install(path: Path, install_raw: Any) -> Optional[InstallSpec]:
@@ -254,8 +244,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
if mv != _MANIFEST_VERSION:
raise CatalogError(
f"{path}: manifest_version {mv!r} unsupported "
f"(this Hermes understands version {_MANIFEST_VERSION})"
)
f"(this Hermes understands version {_MANIFEST_VERSION})")
name = data.get("name") or ""
if not name or not re.match(r"^[A-Za-z0-9_-]+$", name):
raise CatalogError(f"{path}: invalid or missing 'name'")
@@ -279,8 +268,7 @@ def _parse_manifest(path: Path) -> CatalogEntry:
install=install,
post_install=str(data.get("post_install") or ""),
suggest=suggest,
manifest_path=path,
)
manifest_path=path)
# Populated by list_catalog(); inspected by the picker / catalog UIs so the user gets actionable
@@ -325,9 +313,6 @@ def get_entry(name: str) -> Optional[CatalogEntry]:
return next((e for e in list_catalog() if e.name == name), None)
# ─── Status helpers ──────────────────────────────────────────────────────────
def installed_servers() -> Dict[str, dict]:
"""Return current ``mcp_servers`` block from config.yaml."""
servers = load_config().get("mcp_servers") or {}
@@ -366,9 +351,6 @@ def remove_server(name: str) -> bool:
return True
# ─── Install ─────────────────────────────────────────────────────────────────
def _say(msg: str, colour: str = Colors.GREEN) -> None:
print(color(msg, colour))
@@ -533,8 +515,7 @@ def _apply_tool_selection(
entry: CatalogEntry,
*,
prior_selection: Optional[List[str]],
prior_exclude: Optional[List[str]] = None,
) -> None:
prior_exclude: Optional[List[str]] = None) -> None:
"""Probe the server and let the user pick which tools to enable.
Probe-success: curses checklist; pre-check priority *prior_selection* (reinstall) > manifest
@@ -557,8 +538,7 @@ def _apply_tool_selection(
_write_tools_filter(name, "exclude", entry.tools.default_excluded)
_say(
f" Applied manifest exclude list ({len(entry.tools.default_excluded)} entries); "
f"everything else stays enabled. {edit_hint}"
)
f"everything else stays enabled. {edit_hint}")
return
_say(f" Probing '{name}' for available tools...", Colors.CYAN)
@@ -573,8 +553,7 @@ def _apply_tool_selection(
_write_tools_filter(name, "include", prior_selection)
msg = (
f" Couldn't probe server. Kept your previous tool selection "
f"({len(prior_selection)} tools). {refine_hint}"
)
f"({len(prior_selection)} tools). {refine_hint}")
elif prior_exclude is not None:
_write_tools_filter(name, "exclude", prior_exclude)
msg = f" Couldn't probe server. Kept your existing exclude list ({len(prior_exclude)} entries)."
@@ -582,14 +561,12 @@ def _apply_tool_selection(
_write_tools_filter(name, "include", manifest_default)
msg = (
f" Couldn't probe server. Applied manifest default "
f"({len(manifest_default)} tools). {refine_hint}"
)
f"({len(manifest_default)} tools). {refine_hint}")
else:
_write_tools_filter(name, "include", None)
msg = (
" Couldn't probe server; installed with no tool filter (all tools enabled when "
f"reachable). Run {configure_hint} after first connect to prune."
)
f"reachable). Run {configure_hint} after first connect to prune.")
_say(msg, Colors.YELLOW)
return
@@ -604,7 +581,9 @@ def _apply_tool_selection(
import sys as _sys
if not _sys.stdin.isatty():
preferred = prior_selection if prior_selection is not None else (entry.tools.default_enabled or None)
_write_tools_filter(name, "include", None if preferred is None else [n for n in preferred if n in tool_names])
_write_tools_filter(
name, "include", None if preferred is None else [n for n in preferred if n in tool_names]
)
return
pre_set = {n for n in (prior_selection or entry.tools.default_enabled or tool_names) if n in tool_names}
@@ -615,8 +594,7 @@ def _apply_tool_selection(
labels = [f"{n} — {(d[:60] + '...') if len(d) > 60 else d}" for n, d in probed]
chosen_indices = curses_checklist(
f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices,
)
f"Select tools for '{name}' (SPACE toggle, ENTER confirm)", labels, pre_indices)
if not chosen_indices:
# Everything unchecked: write an empty include so the server is installed but contributes
# nothing until reconfigured.
@@ -629,8 +607,7 @@ def _apply_tool_selection(
_write_tools_filter(name, "include", None)
_say(
f" ✓ All {len(probed)} tools enabled (no filter — new tools "
"the server adds later will be auto-enabled)."
)
"the server adds later will be auto-enabled).")
return
chosen_names = [tool_names[i] for i in sorted(chosen_indices)]
_write_tools_filter(name, "include", chosen_names)
@@ -665,14 +642,12 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
f" This MCP uses {entry.auth.provider} OAuth. Run "
f"`hermes auth {entry.auth.provider}` if you have not "
"already authenticated.",
Colors.YELLOW,
)
Colors.YELLOW)
elif entry.auth.type == "oauth":
_say(
" This MCP uses native OAuth 2.1; tokens will be acquired "
"on first connection (browser flow).",
Colors.DIM,
)
Colors.DIM)
# Read prior user selection BEFORE overwriting the entry so a reinstall preserves it.
prior_selection = _read_prior_tool_list(entry.name, "include")
@@ -692,8 +667,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
_say(
f" ✓ Installed '{entry.name}' "
f"({'enabled' if enable else 'disabled'}). "
f"Start a new Hermes session to load its tools."
)
f"Start a new Hermes session to load its tools.")
if entry.post_install:
print()
for line in entry.post_install.strip().splitlines():
@@ -702,7 +676,7 @@ def install_entry(entry: CatalogEntry, *, enable: bool = True) -> None:
def uninstall_entry(name: str, *, purge_install_dir: bool = True) -> bool:
"""Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything was removed."""
"""Remove a catalog-installed MCP from config and (optionally) its clone dir. True if anything removed."""
removed = remove_server(name)
if purge_install_dir:
clone = _install_root() / name
+66 -81
View File
@@ -28,19 +28,11 @@ _MCP_PRESETS: Dict[str, Dict[str, Any]] = {
"codex": {"command": "codex", "args": ["mcp-server"]},
}
# ─── UI Helpers ───────────────────────────────────────────────────────────────
def _info(text: str):
print(color(f" {text}", Colors.DIM))
def _success(text: str):
print(color(f" ✓ {text}", Colors.GREEN))
def _warning(text: str):
print(color(f" ⚠ {text}", Colors.YELLOW))
def _error(text: str):
print(color(f" ✗ {text}", Colors.RED))
def _info(text: str): print(color(f" {text}", Colors.DIM))
def _success(text: str): print(color(f" ✓ {text}", Colors.GREEN))
def _warning(text: str): print(color(f" ⚠ {text}", Colors.YELLOW))
def _error(text: str): print(color(f" ✗ {text}", Colors.RED))
def _confirm(question: str, default: bool = True) -> bool:
@@ -59,8 +51,6 @@ def _print_tools(tools: List[Tuple[str, str]], width: int, desc_max: int) -> Non
print(f" {color(tool_name, Colors.GREEN):{width}s} {short}")
# ─── Config Helpers ───────────────────────────────────────────────────────────
def _get_mcp_servers(config: Optional[dict] = None) -> Dict[str, dict]:
"""Return the ``mcp_servers`` dict from config, or empty dict."""
if config is None:
@@ -77,8 +67,7 @@ def _tool_filters(cfg: dict) -> Tuple[Optional[list], Optional[list]]:
include, exclude = tools_cfg.get("include"), tools_cfg.get("exclude")
return (
include if include and isinstance(include, list) else None,
exclude if exclude and isinstance(exclude, list) else None,
)
exclude if exclude and isinstance(exclude, list) else None)
def _save_mcp_server(name: str, server_config: dict) -> bool:
@@ -215,8 +204,7 @@ def _apply_mcp_preset(
url: Optional[str],
command: Optional[str],
cmd_args: List[str],
server_config: Dict[str, Any],
) -> tuple[Optional[str], Optional[str], List[str], bool]:
server_config: Dict[str, Any]) -> tuple[Optional[str], Optional[str], List[str], bool]:
"""Apply a known MCP preset when transport details were omitted."""
if not preset_name:
return url, command, cmd_args, False
@@ -236,8 +224,6 @@ def _apply_mcp_preset(
return url, command, cmd_args, True
# ─── Discovery (temporary connect) ───────────────────────────────────────────
def _resolve_mcp_server_config(config: dict) -> dict:
"""Resolve ``${ENV}`` placeholders in a server config before connecting.
@@ -269,8 +255,7 @@ def _probe_single_server(
raise ValueError("; ".join(issues))
from tools.mcp_tool import (
_ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish,
)
_ensure_mcp_loop, _run_on_mcp_loop, _connect_server, _stop_mcp_loop_if_idle, _parse_boolish)
config = _resolve_mcp_server_config(config)
if connect_timeout is None:
@@ -299,10 +284,8 @@ def _probe_single_server(
details["schema_chars"] = {
t.name: len(_json.dumps(
_convert_mcp_schema(name, t), separators=(",", ":"), default=str,
))
for t in server._tools
}
_convert_mcp_schema(name, t), separators=(",", ":"), default=str))
for t in server._tools}
except Exception: # pragma: no cover — display-only extra
pass
# Gate capability probes like runtime registration (_select_utility_schemas):
@@ -357,9 +340,9 @@ def _unwrap_exception_group(exc: BaseException) -> Exception:
return exc if isinstance(exc, Exception) else RuntimeError(str(exc))
# ─── hermes mcp add ──────────────────────────────────────────────────────────
def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any]) -> bool:
def _configure_http_auth(
name: str, url: str, auth_type: Optional[str], server_config: Dict[str, Any]
) -> bool:
"""OAuth or Bearer-token setup for an HTTP server. False when the user cancelled."""
print()
if auth_type == "oauth":
@@ -383,7 +366,8 @@ def _configure_http_auth(name: str, url: str, auth_type: Optional[str], server_c
return True
_info(f"Connecting to {url}")
if _confirm("Does this server require authentication?", default=True) and (auth_type == "header" or not auth_type):
needs_auth = _confirm("Does this server require authentication?", default=True)
if needs_auth and (auth_type == "header" or not auth_type):
env_key = _env_key_for_server(name)
if get_env_value(env_key):
_success(f"{env_key}: already configured")
@@ -405,7 +389,9 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s
_print_tools(tools, 40, 60)
print()
try:
choice = input(color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)).strip().lower()
choice = input(
color(f" Enable all {len(tools)} tools? [Y/n/select]: ", Colors.YELLOW)
).strip().lower()
except (KeyboardInterrupt, EOFError):
print()
_info("Cancelled.")
@@ -417,7 +403,8 @@ def _choose_tools(name: str, tools: List[Tuple[str, str]], server_config: Dict[s
return len(tools)
from hermes_cli.curses_ui import curses_checklist
chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in tools], set(range(len(tools))))
labels = [f"{t[0]} — {t[1]}" for t in tools]
chosen = curses_checklist(f"Select tools for '{name}'", labels, set(range(len(tools))))
if not chosen:
_info("No tools selected — server not saved.")
return None
@@ -443,8 +430,7 @@ def cmd_mcp_add(args):
explicit_env = _parse_env_assignments(getattr(args, "env", None))
url, command, cmd_args, _preset_applied = _apply_mcp_preset(
name, preset_name=getattr(args, "preset", None), url=url, command=command,
cmd_args=list(cmd_args), server_config=server_config,
)
cmd_args=list(cmd_args), server_config=server_config)
except ValueError as exc:
_error(str(exc))
return
@@ -460,7 +446,9 @@ def cmd_mcp_add(args):
_info(' hermes mcp add myserver --preset mypreset')
return
if name in _get_mcp_servers() and not _confirm(f"Server '{name}' already exists. Overwrite?", default=False):
if name in _get_mcp_servers() and not _confirm(
f"Server '{name}' already exists. Overwrite?", default=False
):
_info("Cancelled.")
return
@@ -505,12 +493,12 @@ def cmd_mcp_add(args):
server_config["enabled"] = True
if _save_mcp_server(name, server_config):
print()
_success(f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)")
_success(
f"Saved '{name}' to {display_hermes_home()}/config.yaml ({tool_count}/{len(tools)} tools enabled)"
)
_info("Start a new session to use these tools.")
# ─── hermes mcp remove ───────────────────────────────────────────────────────
def cmd_mcp_remove(args):
"""Remove an MCP server from config."""
name = args.name
@@ -531,8 +519,6 @@ def cmd_mcp_remove(args):
pass
# ─── hermes mcp list ──────────────────────────────────────────────────────────
def cmd_mcp_list(args=None):
"""List all configured MCP servers."""
servers = _get_mcp_servers()
@@ -566,7 +552,12 @@ def cmd_mcp_list(args=None):
transport = transport[:25] + "..."
include, exclude = _tool_filters(cfg)
tools_str = f"{len(include)} selected" if include else f"-{len(exclude)} excluded" if exclude else "all"
if include:
tools_str = f"{len(include)} selected"
elif exclude:
tools_str = f"-{len(exclude)} excluded"
else:
tools_str = "all"
enabled = cfg.get("enabled", True)
if isinstance(enabled, str):
@@ -576,8 +567,6 @@ def cmd_mcp_list(args=None):
print()
# ─── hermes mcp test ──────────────────────────────────────────────────────────
def cmd_mcp_test(args):
"""Test connection to an MCP server."""
name = args.name
@@ -618,8 +607,6 @@ def cmd_mcp_test(args):
print()
# ─── hermes mcp login ────────────────────────────────────────────────────────
def _reauth_oauth_server(name: str, server_config: dict) -> bool:
"""Force a fresh OAuth flow for one server. Returns True on success.
@@ -657,7 +644,9 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:
except (TypeError, ValueError):
_login_connect_timeout = 0.0
with force_interactive_oauth():
tools = _probe_single_server(name, server_config, connect_timeout=max(_login_connect_timeout, 315.0))
tools = _probe_single_server(
name, server_config, connect_timeout=max(_login_connect_timeout, 315.0)
)
# A clean probe is NOT proof of authentication: some servers (e.g. Google Drive) serve
# initialize + tools/list without auth, so the flow may have failed (e.g. DCR 400 for
# providers without RFC 7591) while the probe still lists tools. Verify a token landed.
@@ -667,12 +656,12 @@ def _reauth_oauth_server(name: str, server_config: dict) -> bool:
_info(
"Some providers (e.g. Google Drive, Atlassian) do not support "
"automatic client registration. For those you must create an "
"OAuth client yourself and add its credentials to config.yaml:"
)
"OAuth client yourself and add its credentials to config.yaml:")
print()
for line in (
"mcp_servers:", f" {name}:", f" url: {url}", " auth: oauth", " oauth:",
' client_id: "<your-oauth-client-id>"', ' client_secret: "<your-oauth-client-secret>"',
' client_id: "<your-oauth-client-id>"',
' client_secret: "<your-oauth-client-secret>"',
):
print(color(f" {line}", Colors.DIM))
print()
@@ -732,9 +721,9 @@ def cmd_mcp_reauth(args):
_reauth_oauth_server(name, cfg)
# ─── hermes mcp configure ────────────────────────────────────────────────────
def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter) -> List[str]:
def _rebuild_exclude_list(
name: str, exclude: list, tool_names: List[str], chosen: set, matches_name_filter
) -> List[str]:
"""New ``tools.exclude`` for an exclude-mode entry after a checklist edit.
Stays in exclude mode rather than demoting the user's dynamic filter to a frozen include list:
@@ -747,11 +736,12 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose
unchecked = {tool_names[i] for i in range(len(tool_names)) if i not in chosen}
checked = {tool_names[i] for i in chosen}
new_literals = (literal_entries - checked) | {
tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude))
}
tn for tn in unchecked if not matches_name_filter(tn, set(old_exclude))}
# A re-checked tool still matched by a kept glob can't be enabled without dropping the glob —
# surface that instead of silently ignoring the click or silently freezing the config.
glob_shadowed = sorted(tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries)))
glob_shadowed = sorted(
tn for tn in checked if glob_entries and matches_name_filter(tn, set(glob_entries))
)
if glob_shadowed:
_warning(
f"{len(glob_shadowed)} re-enabled tool(s) still match glob "
@@ -759,8 +749,7 @@ def _rebuild_exclude_list(name: str, exclude: list, tool_names: List[str], chose
f"{', '.join(glob_shadowed[:5])}"
f"{' ...' if len(glob_shadowed) > 5 else ''}. Remove the "
f"pattern from mcp_servers.{name}.tools.exclude in "
"config.yaml to enable them."
)
"config.yaml to enable them.")
return glob_entries + sorted(new_literals)
@@ -797,12 +786,11 @@ def cmd_mcp_configure(args):
def matches_name_filter(tool_name, patterns):
return tool_name in patterns
if include:
include_set = {str(p) for p in include}
pre_selected = {i for i, tn in enumerate(tool_names) if matches_name_filter(tn, include_set)}
elif exclude:
exclude_set = {str(p) for p in exclude}
pre_selected = {i for i, tn in enumerate(tool_names) if not matches_name_filter(tn, exclude_set)}
patterns = {str(p) for p in (include or exclude or [])}
if patterns:
pre_selected = {
i for i, tn in enumerate(tool_names) if matches_name_filter(tn, patterns) == bool(include)
}
else:
pre_selected = set(range(total))
@@ -811,7 +799,8 @@ def cmd_mcp_configure(args):
from hermes_cli.curses_ui import curses_checklist
chosen = curses_checklist(f"Select tools for '{name}'", [f"{t[0]} — {t[1]}" for t in all_tools], pre_selected)
labels = [f"{t[0]} — {t[1]}" for t in all_tools]
chosen = curses_checklist(f"Select tools for '{name}'", labels, pre_selected)
if chosen == pre_selected:
_info("No changes made.")
return
@@ -841,8 +830,6 @@ def cmd_mcp_configure(args):
_info("Start a new session for changes to take effect.")
# ─── Dispatcher ───────────────────────────────────────────────────────────────
_MCP_USAGE = (
"hermes mcp Open the catalog picker (default)",
"hermes mcp catalog List Nous-approved MCPs",
@@ -867,21 +854,19 @@ def mcp_command(args):
from mcp_serve import run_mcp_server
run_mcp_server(verbose=getattr(args, "verbose", False))
return
# Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap.
if action == "picker":
from hermes_cli.mcp_picker import run_picker
run_picker()
return
if action == "catalog":
from hermes_cli.mcp_picker import show_catalog
show_catalog()
return
if action == "install":
from hermes_cli.mcp_picker import install_by_name
import sys as _sys
rc = install_by_name(getattr(args, "identifier", "") or "")
if rc:
_sys.exit(rc)
if action in ("picker", "catalog", "install"):
# Catalog subcommands live in mcp_picker / mcp_catalog; import lazily to keep this module cheap.
from hermes_cli import mcp_picker
if action == "picker":
mcp_picker.run_picker()
elif action == "catalog":
mcp_picker.show_catalog()
else:
import sys as _sys
rc = mcp_picker.install_by_name(getattr(args, "identifier", "") or "")
if rc:
_sys.exit(rc)
return
handler = {
"add": cmd_mcp_add, "remove": cmd_mcp_remove, "rm": cmd_mcp_remove, "list": cmd_mcp_list,
+8 -13
View File
@@ -79,8 +79,7 @@ def _enable_disable(name: str, *, enable: bool) -> None:
print(color(
f" ✓ '{name}' {'enabled' if enable else 'disabled'}. "
"Start a new Hermes session for changes to take effect.",
Colors.GREEN,
))
Colors.GREEN))
def _configure_tools(name: str) -> None:
@@ -119,8 +118,7 @@ def _uninstall(name: str) -> None:
print(color(
f" ✓ Uninstalled '{name}'. "
"Credentials in .env preserved — delete manually if no longer needed.",
Colors.GREEN,
))
Colors.GREEN))
else:
print(color(f" '{name}' was not installed", Colors.DIM))
@@ -146,8 +144,7 @@ def _handle_row(row: _Row) -> None:
("Configure tools (probe server + re-pick)", lambda: _configure_tools(row.name)),
("Enable" if not enabled else "Disable",
lambda: _enable_disable(row.name, enable=not is_enabled(row.name))),
("Remove from config", lambda: _remove_custom(row.name)),
])
("Remove from config", lambda: _remove_custom(row.name))])
return
# Catalog row, installed + enabled
print()
@@ -158,8 +155,7 @@ def _handle_row(row: _Row) -> None:
lambda: _enable_disable(row.name, enable=False)),
("Uninstall (remove config and any cloned files)", lambda: _uninstall(row.name)),
("Reinstall (re-clone, re-prompt for credentials)",
lambda: _install(row.entry, "reinstall")),
])
lambda: _install(row.entry, "reinstall"))])
def _print_rows_text(rows: List[_Row]) -> None:
@@ -186,8 +182,7 @@ def _print_rows_text(rows: List[_Row]) -> None:
print(color(
f" ⚠ '{name}' requires a newer Hermes — run `hermes update` "
"to install this entry.",
Colors.YELLOW,
))
Colors.YELLOW))
print()
print()
@@ -206,7 +201,8 @@ def run_picker() -> None:
_print_rows_text(rows) # non-interactive: degrade to the text dump
return
idx = curses_single_select(
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit", [_format_row(r) for r in rows],
"MCP Catalog — ↑↓ navigate ENTER act on entry ESC/q quit",
[_format_row(r) for r in rows],
)
if idx is None:
return
@@ -222,7 +218,6 @@ def install_by_name(identifier: str) -> int:
print(color(
f" ✗ '{identifier}' is not in the catalog. "
"Run `hermes mcp catalog` to see available entries.",
Colors.RED,
))
Colors.RED))
return 1
return 0 if _install(entry, "install") else 1
+3 -6
View File
@@ -103,8 +103,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
# One IOC is enough to refuse; don't leak the full match list.
issues.append(
f"MCP server '{name}' contains a known hermes-0day "
f"indicator-of-compromise ('{ioc}')"
)
f"indicator-of-compromise ('{ioc}')")
return issues
command = entry.get("command")
@@ -117,8 +116,7 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
if _EGRESS_PATTERN.search(script):
issue = (
f"MCP server '{name}' uses shell interpreter '{command}' with "
f"network egress in args"
)
f"network egress in args")
if _EXFIL_HINT_PATTERN.search(script):
issue += " and exfiltration-shaped arguments"
issues.append(issue)
@@ -127,6 +125,5 @@ def validate_mcp_server_entry(name: str, entry: dict[str, Any]) -> list[str]:
f"MCP server '{name}' uses shell interpreter '{command}' to write "
f"to an OS persistence surface (SSH keys / PAM / sudoers / cron / "
f"shell rc) — this is the hermes-0day backdoor shape, not a real "
f"MCP server"
)
f"MCP server")
return issues
+2 -4
View File
@@ -55,8 +55,7 @@ def start_background_mcp_discovery(*, logger, thread_name: str) -> None:
return
logger.warning(
"Background MCP discovery previously exited with no connected "
"servers; retrying discovery thread"
)
"servers; retrying discovery thread")
_mcp_discovery_started = False
_mcp_discovery_thread = None
@@ -201,8 +200,7 @@ def ensure_mcp_discovery_before_agent_build(
logger,
timeout: "float | None" = None,
single_query: bool = False,
thread_name: str = "cli-mcp-discovery",
) -> None:
thread_name: str = "cli-mcp-discovery") -> None:
"""Give configured MCP tools a bounded chance to register before AIAgent.
Non-interactive first turns (``chat -q``, ``hermes -z``) can construct ``AIAgent`` before any
+1 -1
View File
@@ -49,7 +49,7 @@ def _scope_to_profile(profile: Optional[str]):
@router.post("/{provider}/oauth/start")
async def start_memory_oauth(provider: str, profile: Optional[str] = None):
"""Begin a provider's zero-CLI OAuth flow (browser + loopback listener). Returns immediately; poll status."""
"""Begin a provider's zero-CLI OAuth flow (browser + loopback listener); returns immediately, poll status."""
flow = _resolve_flow(provider)
try:
# The flow resolves its config path eagerly inside this scope; its worker thread outlives it.
+6 -31
View File
@@ -17,8 +17,7 @@ _IMPORT_NAMES = {
"honcho-ai": "honcho",
"mem0ai": "mem0",
"hindsight-client": "hindsight_client",
"hindsight-all": "hindsight",
}
"hindsight-all": "hindsight"}
def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
@@ -41,16 +40,8 @@ def _provider_pip_dependencies(provider_name: str, declared: list) -> list:
return deps
# ---------------------------------------------------------------------------
# Curses-based interactive picker (same pattern as hermes tools)
# ---------------------------------------------------------------------------
def _curses_select(
title: str,
items: list[tuple[str, str]],
default: int = 0,
*,
cancel_returns: int | None = None,
title: str, items: list[tuple[str, str]], default: int = 0, *, cancel_returns: int | None = None
) -> int:
"""Interactive single-select with arrow keys."""
from hermes_cli.curses_ui import curses_radiolist
@@ -87,10 +78,6 @@ def _prompt(label: str, default: str | None = None, secret: bool = False) -> str
return val or (default or "")
# ---------------------------------------------------------------------------
# Provider discovery
# ---------------------------------------------------------------------------
def _install_dependencies(provider_name: str, *, force: bool = False) -> None:
"""Install pip dependencies declared in ``plugin.yaml``.
@@ -207,10 +194,6 @@ def _find_provider(providers: list, provider_name: str):
return next((p for p in providers if p[0] == provider_name), None)
# ---------------------------------------------------------------------------
# Setup wizard
# ---------------------------------------------------------------------------
def _post_setup_hook(provider, config: dict) -> bool:
"""Normalize the ``memory`` block; True when the provider's ``post_setup`` took over (it owns
config, connection test and activation), so the caller must stop."""
@@ -271,7 +254,9 @@ def _prompt_schema_fields(name: str, schema: list, provider_config: dict, env_wr
if choices and not is_secret:
current = provider_config.get(key, default)
current_idx = choices.index(current) if current and current in choices else 0
sel = _curses_select(f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED)
sel = _curses_select(
f" {desc}", [(c, "") for c in choices], default=current_idx, cancel_returns=_CANCELLED
)
if sel == _CANCELLED:
_print_cancelled_setup()
return False
@@ -360,9 +345,7 @@ def cmd_setup(args) -> None:
def _write_env_vars(
env_writes: dict,
hermes_home: str | os.PathLike[str] | None = None,
) -> None:
env_writes: dict, hermes_home: str | os.PathLike[str] | None = None) -> None:
"""Persist memory-provider env vars through the canonical ``.env`` writer.
``save_env_value`` applies the shared gate (name regex, ``LD_PRELOAD``/``PYTHONPATH``/``HERMES_HOME``
@@ -385,10 +368,6 @@ def _write_env_vars(
reset_hermes_home_override(token)
# ---------------------------------------------------------------------------
# Status
# ---------------------------------------------------------------------------
def _mark(enabled) -> str:
return "enabled ✓" if enabled else "disabled ✗"
@@ -466,10 +445,6 @@ def cmd_status(args) -> None:
print()
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
def memory_command(args) -> None:
"""Route memory subcommands."""
if getattr(args, "memory_command", None) == "setup":
+18 -23
View File
@@ -60,7 +60,9 @@ def _safe_copy(payload: Any) -> Any:
return dict(payload) if isinstance(payload, dict) else payload
def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any) -> Dict[str, Any]:
def _apply_request_chain(
kind: str, payload_key: str, trace: List[Dict[str, Any]], **kwargs: Any
) -> Dict[str, Any]:
"""Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``."""
from hermes_cli.plugins import invoke_middleware
@@ -75,8 +77,7 @@ def _apply_request_chain(kind: str, payload_key: str, trace: List[Dict[str, Any]
entry = {
key: value
for key in ("source", "reason", "name")
if isinstance(value := result.get(key), str) and value
}
if isinstance(value := result.get(key), str) and value}
trace.append(entry or {"source": "plugin"})
return current
@@ -92,14 +93,15 @@ def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> Req
trace: List[Dict[str, Any]] = []
current_request = _apply_request_chain(
LLM_REQUEST_MIDDLEWARE, "request", trace,
request=_safe_copy(original_request), original_request=original_request, **context,
)
request=_safe_copy(original_request), original_request=original_request, **context)
return RequestMiddlewareResult(
payload=current_request, original_payload=original_request, changed=bool(trace), trace=trace,
)
def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **context: Any) -> RequestMiddlewareResult:
def apply_tool_request_middleware(
tool_name: str, args: Dict[str, Any], **context: Any
) -> RequestMiddlewareResult:
"""Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool
arguments before hooks, guardrails, approvals, and execution see them."""
original_args = _safe_copy(args)
@@ -112,8 +114,7 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex
from agent import relay_runtime
relay_args = relay_runtime.apply_tool_request_intercepts(
session_id=session_id, tool_name=tool_name, args=current_args,
)
session_id=session_id, tool_name=tool_name, args=current_args)
if relay_args != current_args:
current_args = _safe_copy(relay_args)
trace.append({"source": "nemo_relay"})
@@ -122,25 +123,22 @@ def apply_tool_request_middleware(tool_name: str, args: Dict[str, Any], **contex
if not has_middleware(TOOL_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=args if not trace else current_args, original_payload=args, changed=bool(trace), trace=trace,
payload=args if not trace else current_args, original_payload=args,
changed=bool(trace), trace=trace,
)
current_args = _apply_request_chain(
TOOL_REQUEST_MIDDLEWARE, "args", trace,
tool_name=tool_name, args=current_args, original_args=original_args, **context,
)
tool_name=tool_name, args=current_args, original_args=original_args, **context)
return RequestMiddlewareResult(
payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace,
)
payload=current_args, original_payload=original_args, changed=bool(trace), trace=trace)
def run_llm_execution_middleware(
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any,
) -> Any:
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any:
"""Run provider execution through registered LLM execution middleware."""
return _run_execution_chain(
LLM_EXECUTION_MIDDLEWARE, next_call,
request=request, original_request=context.pop("original_request", request), **context,
)
request=request, original_request=context.pop("original_request", request), **context)
def run_tool_execution_middleware(
@@ -149,8 +147,7 @@ def run_tool_execution_middleware(
"""Run tool execution through registered tool execution middleware."""
return _run_execution_chain(
TOOL_EXECUTION_MIDDLEWARE, next_call,
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context,
)
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context)
class _DownstreamExecutionError(Exception):
@@ -187,8 +184,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg
raise RuntimeError(
f"Middleware '{kind}' callback "
f"{getattr(callback, '__name__', repr(callback))} called "
"next_call() more than once; downstream execution is single-use"
)
"next_call() more than once; downstream execution is single-use")
next_called = True
try:
next_result = call_at(index + 1, payload if next_payload is None else next_payload)
@@ -207,8 +203,7 @@ def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwarg
except Exception as exc:
logger.warning(
"Middleware '%s' callback %s raised: %s",
kind, getattr(callback, "__name__", repr(callback)), exc,
)
kind, getattr(callback, "__name__", repr(callback)), exc)
if next_succeeded:
return next_result
if next_called: