diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index c4a30dabb2..1633e1b93e 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -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) diff --git a/hermes_cli/doctor_config.py b/hermes_cli/doctor_config.py index 4ecc939f84..cf9e319261 100644 --- a/hermes_cli/doctor_config.py +++ b/hermes_cli/doctor_config.py @@ -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}") diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py index 275848311b..348c10a5bf 100644 --- a/hermes_cli/doctor_live.py +++ b/hermes_cli/doctor_live.py @@ -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 diff --git a/hermes_cli/doctor_platform.py b/hermes_cli/doctor_platform.py index 1f02ade5cb..42e5534210 100644 --- a/hermes_cli/doctor_platform.py +++ b/hermes_cli/doctor_platform.py @@ -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: diff --git a/hermes_cli/doctor_state.py b/hermes_cli/doctor_state.py index 5b2fa255c7..0c2b9a3ddd 100644 --- a/hermes_cli/doctor_state.py +++ b/hermes_cli/doctor_state.py @@ -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" - "\n\n" - "You are Hermes, a helpful AI assistant.\n", - encoding="utf-8", - ) + soul_path.write_text("# Hermes Agent Persona\n\n\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})") diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index 6785627a27..051c5d0ffb 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -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: diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index c56b8836d9..cf18dc33fe 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -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)