refactor(hermes_cli): doctor — doctor_check on trivial wrappers, join wrapped message args, drop in-function blank lines

This commit is contained in:
Teknium
2026-09-02 21:30:49 -07:00
parent d6029d372f
commit aa6ca6097e
7 changed files with 38 additions and 106 deletions

View File

@@ -96,7 +96,6 @@ def _check_auth_providers(should_fix: bool) -> Finding:
_login_row("MiniMax OAuth", minimax_status, f"(logged in, region={minimax_status.get('region', 'global')})")
except Exception as e:
check_warn("Auth provider status", f"(could not check: {e})")
# xAI OAuth — separate try/except so an import failure here cannot
# disrupt the already-printed Nous/Codex/MiniMax rows above.
try:
@@ -199,29 +198,23 @@ def run_doctor(args):
"""Run diagnostic checks."""
should_fix = getattr(args, 'fix', False)
ack_target = getattr(args, 'ack', None)
# Doctor runs from the interactive CLI, so CLI-gated tool checks (e.g. cronjob) see the same context.
os.environ.setdefault("HERMES_INTERACTIVE", "1")
if ack_target:
return _ack_advisory(ack_target)
print()
print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
print(color("│ 🩺 Hermes Doctor │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
total = Finding()
for title, check in DOCTOR_CHECKS:
if title:
_section(title)
total.merge(check(should_fix))
# Opt-in live probes run AFTER all static checks (`--live`: real network calls; bounded + read-only).
try:
from hermes_cli.doctor_live import maybe_run_live_checks
maybe_run_live_checks(args, total.manual_issues)
except Exception:
pass
_print_summary(should_fix, total)

View File

@@ -140,9 +140,7 @@ def _check_mcp_security(should_fix: bool, f: Finding) -> None:
continue
suspicious += 1
check_warn(f"MCP server '{name}' has suspicious stdio command", "; ".join(issues_found))
f.manual_issues.append(
f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed."
)
f.manual_issues.append(f"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.")
if suspicious == 0:
check_ok("No suspicious MCP stdio commands")
@@ -287,14 +285,10 @@ def _validate_model_config(config_path, issues: list) -> None:
policy_id = str(runtime_provider or catalog_provider or "").strip().lower()
accepts_vendor_slug = policy_id in _VENDOR_SLUG_PROVIDERS or policy_id == "custom" or policy_id.startswith("custom:")
if default_model and "/" in default_model and policy_id and not accepts_vendor_slug:
check_warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)",
)
issues.append(
f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. "
"Either set model.provider to 'openrouter', or drop the vendor prefix."
)
check_warn(f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)")
issues.append(f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. "
"Either set model.provider to 'openrouter', or drop the vendor prefix.")
if runtime_provider and runtime_provider not in ("auto", "custom"):
from hermes_cli.doctor import _DHH
@@ -477,6 +471,4 @@ def _check_xai_retirement(should_fix: bool, f: Finding) -> None:
for ref in retired_refs:
check_warn(format_issue(ref))
check_info(f"Migration guide: {MIGRATION_GUIDE_URL}")
f.manual_issues.append(
f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}"
)
f.manual_issues.append(f"Update {len(retired_refs)} retired xAI model reference(s) in config.yaml — see {MIGRATION_GUIDE_URL}")

View File

@@ -133,7 +133,6 @@ def _probe_audio(kind: str, config: dict, timeout: float) -> ProbeResult:
provider = (((config.get(kind) or {}).get("provider")) or "").strip().lower()
if provider in _LOCAL_AUDIO_PROVIDERS:
return ProbeResult(name, "skip", f"(provider '{provider or 'local'}' — no remote backend to probe)")
entry = _AUDIO_PROBES.get(provider)
if entry is None:
return ProbeResult(name, "skip", f"(provider '{provider}' — no live probe implemented)")
@@ -184,32 +183,26 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]:
except (TypeError, ValueError):
timeout = DEFAULT_PROBE_TIMEOUT
timeout = max(1.0, timeout)
_section("Live Backend Probes (opt-in, real calls)")
results: List[ProbeResult] = [
_run_one(name, lambda n=name, spec=spec: _keyed_probe(n, *spec, timeout), issues)
for name, spec in _KEYED_PROBES.items()
]
results.append(_run_one("Browser", lambda: _probe_browser(timeout), issues))
servers = config.get("mcp_servers") or {}
if isinstance(servers, dict) and servers:
for name in sorted(servers):
entry = servers[name]
def _probe(n=name, e=entry) -> ProbeResult:
if not isinstance(e, dict):
return ProbeResult(f"MCP: {n}", "skip", "(malformed config entry)")
return ProbeResult(f"MCP: {n}", "pass", f"({len(_probe_mcp_server(n, e, timeout))} tool(s))")
results.append(_run_one(f"MCP: {name}", _probe, issues))
else:
results.append(ProbeResult("MCP", "skip", "(no servers configured)"))
_report(results[-1], issues)
for kind in ("tts", "stt"):
results.append(_run_one(kind.upper(), lambda k=kind: _probe_audio(k, config, timeout), issues))
return results

View File

@@ -286,24 +286,17 @@ def check_macos_tcc_grants() -> None:
check_warn("macOS TCC grant check", "(could not read code-signing requirement of the desktop bundle)")
return
if "cdhash" in dr.lower():
check_warn(
"macOS TCC grants will reset after every update",
"the desktop bundle's designated requirement is cdhash-pinned "
"(pre-#73681 build) — rebuilds invalidate all permission grants. "
"Run `hermes update` to get the stable identifier-pinned signing "
"identity, then re-grant permissions once.",
)
return
return check_warn("macOS TCC grants will reset after every update",
"the desktop bundle's designated requirement is cdhash-pinned (pre-#73681 build) — rebuilds invalidate "
"all permission grants. Run `hermes update` to get the stable identifier-pinned signing identity, "
"then re-grant permissions once.")
check_ok("macOS TCC signing identity is stable",
# --setup-tcc-identity or notarized build: strongest anchor
"(certificate-anchored DR; grants survive rebuilds)" if "certificate" in dr.lower() else
"(identifier-pinned DR; grants survive rebuilds — for the strongest anchor, see `hermes desktop --setup-tcc-identity`)")
check_info(
"If macOS still re-prompts for permissions (toggle shows ON): the stored "
"grant is stale — run `tccutil reset ScreenCapture com.nousresearch.hermes` "
"(repeat per affected service), toggle it ON in System Settings, then "
"fully quit & relaunch Hermes once."
)
check_info("If macOS still re-prompts for permissions (toggle shows ON): the stored grant is stale — run "
"`tccutil reset ScreenCapture com.nousresearch.hermes` (repeat per affected service), toggle it ON in "
"System Settings, then fully quit & relaunch Hermes once.")
def _desktop_app_bundle() -> Path | None:
@@ -389,12 +382,8 @@ def _check_security_advisories(should_fix: bool, f: Finding) -> None:
for line in full_remediation_text(hit): # indented under the header as one section
print(f" {color(line, Colors.YELLOW)}" if line else "")
# Also into the action list so the summary block surfaces it.
f.manual_issues.append(
f"Resolve security advisory {hit.advisory.id}: "
f"uninstall {hit.package}=={hit.installed_version} and "
f"rotate credentials, then run "
f"`hermes doctor --ack {hit.advisory.id}`."
)
f.manual_issues.append(f"Resolve security advisory {hit.advisory.id}: uninstall {hit.package}=={hit.installed_version} "
f"and rotate credentials, then run `hermes doctor --ack {hit.advisory.id}`.")
acked_ids = get_acked_ids() # acked-but-still-installed stays visible
for h in all_hits:
if h.advisory.id in acked_ids:
@@ -441,10 +430,9 @@ def _check_python_environment(should_fix: bool) -> Finding:
return f
def _check_certificates(should_fix: bool) -> Finding:
f = Finding()
@doctor_check()
def _check_certificates(should_fix: bool, f: Finding) -> None:
check_certificates(should_fix=should_fix, issues=f.manual_issues)
return f
# (import name, display name, optional)
@@ -469,11 +457,10 @@ def _check_required_packages(should_fix: bool) -> Finding:
return f
def _check_gateway_supervision(should_fix: bool) -> Finding:
f = Finding()
@doctor_check()
def _check_gateway_supervision(should_fix: bool, f: Finding) -> None:
_check_gateway_service_linger(f.issues)
_check_s6_supervision(f.issues)
return f
def _check_command_installation(should_fix: bool) -> Finding:

View File

@@ -61,11 +61,7 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
"""
lines: list = []
stats = stats or {}
logical = stats.get("logical_size_bytes")
wal = stats.get("wal_size_bytes")
freelist = stats.get("freelist_count")
logical, wal, freelist = (stats.get(k) for k in ("logical_size_bytes", "wal_size_bytes", "freelist_count"))
size_bits = _bits(
(logical, lambda: f"logical size {_human_bytes(logical)}"),
(stats.get("page_count"), lambda: f"{stats['page_count']:,} pages"),
@@ -82,21 +78,15 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
)
if row_bits:
lines.append(("info", ", ".join(row_bits), ""))
fts = stats.get("fts_tables")
if fts:
present = [t for t, ok in fts.items() if ok]
lines.append(("info", "FTS tables: " + (", ".join(present) if present else "none"), ""))
deferral = stats.get("fts_rebuild_deferral")
if isinstance(deferral, dict):
attempts = deferral.get("attempts")
pids = deferral.get("holder_pids") or []
lines.append((
"warn",
f"state.db FTS repair is blocked after {attempts or '?'} deferral(s) by PID(s) {pids or 'unknown'}",
"(stop the listed processes, then run 'hermes sessions optimize-storage' with the gateway stopped)",
))
lines.append(("warn", f"state.db FTS repair is blocked after {deferral.get('attempts') or '?'} deferral(s) "
f"by PID(s) {deferral.get('holder_pids') or [] or 'unknown'}",
"(stop the listed processes, then run 'hermes sessions optimize-storage' with the gateway stopped)"))
# Advisory: oversized database. Suggest auto_prune, and — when the v23 FTS rebuild is pending OR
# the DB still carries the legacy inline trigram layout (fts_storage_version marker absent) —
@@ -139,8 +129,7 @@ def _check_directory_structure(should_fix: bool) -> Finding:
if soul_path.exists():
content = soul_path.read_text(encoding="utf-8").strip()
# Template comments only (no real content)?
lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
if lines:
if any(l.strip() and not l.strip().startswith(("<!--", "-->", "#")) for l in content.splitlines()):
check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
else:
check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
@@ -148,12 +137,8 @@ def _check_directory_structure(should_fix: bool) -> Finding:
check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
if should_fix:
soul_path.parent.mkdir(parents=True, exist_ok=True)
soul_path.write_text(
"# Hermes Agent Persona\n\n"
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
"You are Hermes, a helpful AI assistant.\n",
encoding="utf-8",
)
soul_path.write_text("# Hermes Agent Persona\n\n<!-- Edit this file to customize how Hermes communicates. -->\n\n"
"You are Hermes, a helpful AI assistant.\n", encoding="utf-8")
check_ok(f"Created {_DHH}/SOUL.md with basic template")
f.fixed += 1
@@ -263,11 +248,8 @@ def _state_db_stats(issues: list, state_db_path: Path) -> None:
continue
check_warn(_text, _detail)
if "auto_prune" in _detail:
issues.append(
"state.db is large — enable sessions.auto_prune in config.yaml"
+ (" and run 'hermes sessions optimize-storage' offline (gateway stopped)"
if "optimize-storage" in _detail else "")
)
issues.append("state.db is large — enable sessions.auto_prune in config.yaml"
+ (" and run 'hermes sessions optimize-storage' offline (gateway stopped)" if "optimize-storage" in _detail else ""))
except Exception as _stats_exc:
check_info(f"state.db stats unavailable ({_stats_exc})")

View File

@@ -157,8 +157,7 @@ def _check_docker_backend(terminal_env: str, running_in_container: bool, issues:
def _check_ssh_backend(issues: list[str]) -> None:
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if not ssh_host:
_fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues)
return
return _fail_and_issue("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)", "Set TERMINAL_SSH_HOST in .env", issues)
ssh_user, ssh_port, ssh_key = (os.getenv(f"TERMINAL_SSH_{k}") for k in ("USER", "PORT", "KEY"))
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
@@ -205,8 +204,7 @@ def _check_vercel_backend(issues: list[str]) -> None:
elif auth_status.label.startswith("partial"):
_fail_and_issue("Vercel auth incomplete", f"({auth_status.label})", "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", issues)
else:
_fail_and_issue("Vercel auth not configured", f"({auth_status.label})",
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues)
_fail_and_issue("Vercel auth not configured", f"({auth_status.label})", "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues)
for line in auth_status.detail_lines:
check_info(f"Vercel auth {line}")
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
@@ -223,9 +221,8 @@ def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None:
except Exception:
provider = None
if provider is None:
_fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)",
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues)
return
return _fail_and_issue(f"Unknown terminal backend '{terminal_env}'", "(no built-in or plugin backend by that name)",
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues)
for ok, label, detail in provider.doctor_checks():
_require(ok, (label, detail), (label, detail), detail.strip("()"), issues)
@@ -282,11 +279,8 @@ def _check_agent_browser(should_fix: bool) -> bool:
# Almost always a dangling global symlink left by npm postinstall after `hermes update` wiped node_modules.
check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)")
elif _is_termux():
_termux_browser_hints(
"agent-browser is not installed (expected in the tested Termux path)",
"Install it manually later with: npm install -g agent-browser && agent-browser install",
node_installed=True,
)
_termux_browser_hints("agent-browser is not installed (expected in the tested Termux path)",
"Install it manually later with: npm install -g agent-browser && agent-browser install", node_installed=True)
else:
check_warn("agent-browser not installed", "(requires npm/npx on PATH)")
return False
@@ -350,11 +344,8 @@ def _check_node_and_browser(should_fix: bool) -> Finding:
if _check_agent_browser(should_fix) and not _is_termux(): # Chromium check is not a tested Termux path
_check_chromium()
elif _is_termux():
_termux_browser_hints(
"Node.js not found (browser tools are optional in the tested Termux path)",
"Install Node.js on Termux with: pkg install nodejs",
node_installed=False,
)
_termux_browser_hints("Node.js not found (browser tools are optional in the tested Termux path)",
"Install Node.js on Termux with: pkg install nodejs", node_installed=False)
else:
check_warn("Node.js not found", "(optional, needed for browser tools)")
_check_lightpanda()
@@ -385,11 +376,8 @@ def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues
if total == 0:
check_ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
if workspace_scoped:
remedy = "build-tool advisory; clears via lockfile bump"
else:
flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else ""
remedy = f"run: cd {npm_dir} && npm audit fix{flag}"
flag = " --workspaces=false" if audit_extra == ["--workspaces=false"] else ""
remedy = "build-tool advisory; clears via lockfile bump" if workspace_scoped else f"run: cd {npm_dir} && npm audit fix{flag}"
check_warn(f"{label} deps", f"({critical} critical, {high} high, {moderate} moderate — {remedy})")
if workspace_scoped:
check_info(" ^ build-time tooling (not runtime); if manual npm remediation "
@@ -426,7 +414,6 @@ def _check_npm_audit(should_fix: bool) -> Finding:
# Workspace-scoped audits check the root node_modules; standalone dirs check their own.
if ((PROJECT_ROOT if audit_extra else npm_dir) / "node_modules").exists():
_audit_one(npm_bin, npm_dir, label, audit_extra, f.issues)
if _is_termux():
check_info("Termux compatibility fallbacks:")
for note in _TERMUX_INSTALL_ALL_FALLBACK_NOTES:

View File

@@ -20,7 +20,6 @@ def _dotenv_key_names() -> set[str]:
text = get_env_path().read_text(encoding="utf-8", errors="ignore")
except (OSError, UnicodeError):
return set()
names: set[str] = set()
for raw in text.splitlines():
line = raw.strip()
@@ -150,7 +149,6 @@ def _config_overrides(config: dict) -> dict[str, str]:
user_val = user_section.get(key)
if user_val is not None and user_val != default_section.get(key):
overrides[f"{section}.{key}"] = str(user_val)
user_toolsets = config.get("toolsets", [])
if user_toolsets != DEFAULT_CONFIG.get("toolsets", []):
overrides["toolsets"] = str(user_toolsets)