diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 87043bc261..4440b38682 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -195,6 +195,18 @@ def check_info(text: str): print(f" {color('→', Colors.CYAN)} {text}") +def _section(title: str) -> None: + """Print a doctor section banner: blank line + bold cyan ◆ title.""" + print() + print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) + + +def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None: + """Emit a check_fail and append the corresponding fix instruction.""" + check_fail(text, detail) + issues.append(fix) + + def _check_gateway_service_linger(issues: list[str]) -> None: """Warn when a systemd user gateway service will stop after logout.""" try: @@ -214,9 +226,7 @@ def _check_gateway_service_linger(issues: list[str]) -> None: if not unit_path.exists(): return - print() - print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD)) - + _section("Gateway Service") linger_enabled, linger_detail = get_systemd_linger_status() if linger_enabled is True: check_ok("Systemd linger enabled", "(gateway service survives logout)") @@ -373,11 +383,7 @@ def run_doctor(args): print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) - # ========================================================================= - # Check: Security advisories (RUNS FIRST — these are the most urgent) - # ========================================================================= - print() - print(color("◆ Security Advisories", Colors.CYAN, Colors.BOLD)) + _section("Security Advisories") try: from hermes_cli.security_advisories import ( detect_compromised, @@ -423,12 +429,7 @@ def run_doctor(args): # Never let a bug in the advisory check block the rest of doctor. check_warn(f"Security advisory check failed: {e}") - # ========================================================================= - # Check: Python version - # ========================================================================= - print() - print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD)) - + _section("Python Environment") py_version = sys.version_info if py_version >= (3, 11): check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") @@ -438,8 +439,12 @@ def run_doctor(args): elif py_version >= (3, 8): check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)") else: - check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)") - issues.append("Upgrade Python to 3.10+") + _fail_and_issue( + f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", + "(3.10+ required)", + "Upgrade Python to 3.10+", + issues, + ) # Check if in virtual environment in_venv = sys.prefix != sys.base_prefix @@ -448,12 +453,7 @@ def run_doctor(args): else: check_warn("Not in virtual environment", "(recommended)") - # ========================================================================= - # Check: Required packages - # ========================================================================= - print() - print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD)) - + _section("Required Packages") required_packages = [ ("openai", "OpenAI SDK"), ("rich", "Rich (terminal UI)"), @@ -473,8 +473,7 @@ def run_doctor(args): __import__(module) check_ok(name) except ImportError: - check_fail(name, "(missing)") - issues.append(f"Install {name}: {_python_install_cmd()} {module}") + _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", issues) for module, name in optional_packages: try: @@ -483,12 +482,7 @@ def run_doctor(args): except ImportError: check_warn(name, "(optional, not installed)") - # ========================================================================= - # Check: Configuration files - # ========================================================================= - print() - print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD)) - + _section("Configuration Files") # Check ~/.hermes/.env (primary location for user config) env_path = HERMES_HOME / '.env' if env_path.exists(): @@ -611,14 +605,15 @@ def run_doctor(args): and not (provider_ids_to_accept & valid_provider_ids) ): known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" - check_fail( + _fail_and_issue( f"model.provider '{provider_raw}' is not a recognised provider", f"(known: {known_list})", - ) - issues.append( - f"model.provider '{provider_raw}' is unknown. " - f"Valid providers: {known_list}. " - f"Fix: run 'hermes config set model.provider '" + ( + f"model.provider '{provider_raw}' is unknown. " + f"Valid providers: {known_list}. " + f"Fix: run 'hermes config set model.provider '" + ), + issues, ) # Warn if model is set to a provider-prefixed name on a provider that doesn't use them @@ -677,14 +672,15 @@ def run_doctor(args): or status.get("api_key") ) if not configured: - check_fail( + _fail_and_issue( f"model.provider '{runtime_provider}' is set but no API key is configured", "(check ~/.hermes/.env or run 'hermes setup')", - ) - issues.append( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" + ( + f"No credentials found for provider '{runtime_provider}'. " + f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " + f"or switch providers with 'hermes config set model.provider '" + ), + issues, ) except Exception: pass @@ -768,8 +764,7 @@ def run_doctor(args): from hermes_cli.config import validate_config_structure config_issues = validate_config_structure() if config_issues: - print() - print(color("◆ Config Structure", Colors.CYAN, Colors.BOLD)) + _section("Config Structure") for ci in config_issues: if ci.severity == "error": check_fail(ci.message) @@ -782,12 +777,7 @@ def run_doctor(args): except Exception: pass - # ========================================================================= - # Check: Auth providers - # ========================================================================= - print() - print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD)) - + _section("Auth Providers") try: from hermes_cli.auth import ( get_nous_auth_status, @@ -859,12 +849,7 @@ def run_doctor(args): "(optional — only required to import tokens from an existing Codex CLI login)" ) - # ========================================================================= - # Check: Directory structure - # ========================================================================= - print() - print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD)) - + _section("Directory Structure") hermes_home = HERMES_HOME if hermes_home.exists(): check_ok(f"{_DHH} directory exists") @@ -976,13 +961,8 @@ def run_doctor(args): _check_gateway_service_linger(issues) - # ========================================================================= - # Check: Command installation (hermes bin symlink) - # ========================================================================= if sys.platform != "win32": - print() - print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD)) - + _section("Command Installation") # Determine the venv entry point location _venv_bin = None for _venv_name in ("venv", ".venv"): @@ -1056,12 +1036,7 @@ def run_doctor(args): else: issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") - # ========================================================================= - # Check: External tools - # ========================================================================= - print() - print(color("◆ External Tools", Colors.CYAN, Colors.BOLD)) - + _section("External Tools") # Git if _safe_which("git"): check_ok("git") @@ -1087,11 +1062,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok("docker", "(daemon running)") else: - check_fail("docker daemon not running") - issues.append("Start Docker daemon") + _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) else: - check_fail("docker not found", "(required for TERMINAL_ENV=docker)") - issues.append("Install Docker or change TERMINAL_ENV") + _fail_and_issue( + "docker not found", + "(required for TERMINAL_ENV=docker)", + "Install Docker or change TERMINAL_ENV", + issues, + ) elif _safe_which("docker"): check_ok("docker", "(optional)") elif _is_termux(): @@ -1126,11 +1104,14 @@ def run_doctor(args): if result is not None and result.returncode == 0: check_ok(f"SSH connection to {ssh_host}") else: - check_fail(f"SSH connection to {ssh_host}") - issues.append(f"Check SSH configuration for {ssh_host}") + _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) else: - check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)") - issues.append("Set TERMINAL_SSH_HOST in .env") + _fail_and_issue( + "TERMINAL_SSH_HOST not set", + "(required for TERMINAL_ENV=ssh)", + "Set TERMINAL_SSH_HOST in .env", + issues, + ) # Daytona (if using daytona backend) if terminal_env == "daytona": @@ -1138,14 +1119,22 @@ def run_doctor(args): if daytona_key: check_ok("Daytona API key", "(configured)") else: - check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)") - issues.append("Set DAYTONA_API_KEY environment variable") + _fail_and_issue( + "DAYTONA_API_KEY not set", + "(required for TERMINAL_ENV=daytona)", + "Set DAYTONA_API_KEY environment variable", + issues, + ) try: from daytona import Daytona # noqa: F401 — SDK presence check check_ok("daytona SDK", "(installed)") except ImportError: - check_fail("daytona SDK not installed", "(pip install daytona)") - issues.append("Install daytona SDK: pip install daytona") + _fail_and_issue( + "daytona SDK not installed", + "(pip install daytona)", + "Install daytona SDK: pip install daytona", + issues, + ) # Vercel Sandbox (if using vercel_sandbox backend) if terminal_env == "vercel_sandbox": @@ -1155,32 +1144,50 @@ def run_doctor(args): check_ok("Vercel runtime", f"({runtime})") else: supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES) - check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})") - issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}") + _fail_and_issue( + "Vercel runtime unsupported", + f"({runtime}; use {supported})", + f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", + issues, + ) disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() if disk in {"", "0", "51200"}: check_ok("Vercel disk setting", "(uses platform default)") else: - check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)") - issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200") + _fail_and_issue( + "Vercel custom disk unsupported", + "(reset terminal.container_disk to 51200)", + "Vercel Sandbox does not support custom container_disk; use the shared default 51200", + issues, + ) if importlib.util.find_spec("vercel") is not None: check_ok("vercel SDK", "(installed)") else: - check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')") - issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'") + _fail_and_issue( + "vercel SDK not installed", + "(pip install 'hermes-agent[vercel]')", + "Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", + issues, + ) auth_status = describe_vercel_auth() if auth_status.ok: check_ok("Vercel auth", f"({auth_status.label})") elif auth_status.label.startswith("partial"): - check_fail("Vercel auth incomplete", f"({auth_status.label})") - issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together") + _fail_and_issue( + "Vercel auth incomplete", + f"({auth_status.label})", + "Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together", + issues, + ) else: - check_fail("Vercel auth not configured", f"({auth_status.label})") - issues.append( - "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID" + _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}") @@ -1320,12 +1327,7 @@ def run_doctor(args): for note in _termux_install_all_fallback_notes(): check_info(note) - # ========================================================================= - # Check: API connectivity - # ========================================================================= - print() - print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD)) - + _section("API Connectivity") # Refactor: every connectivity probe below is HTTP-bound and fully # independent. Running them in series spent ~5s wall on a typical # workstation (2s of that was boto3's IMDS lookup for AWS credentials, @@ -1673,12 +1675,7 @@ def run_doctor(args): for _issue in _issues_to_add: issues.append(_issue) - # ========================================================================= - # Check: Tool Availability - # ========================================================================= - print() - print(color("◆ Tool Availability", Colors.CYAN, Colors.BOLD)) - + _section("Tool Availability") try: # Add project root to path for imports sys.path.insert(0, str(PROJECT_ROOT)) @@ -1706,12 +1703,7 @@ def run_doctor(args): except Exception as e: check_warn("Could not check tool availability", f"({e})") - # ========================================================================= - # Check: Skills Hub - # ========================================================================= - print() - print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD)) - + _section("Skills Hub") hub_dir = HERMES_HOME / "skills" / ".hub" if hub_dir.exists(): check_ok("Skills Hub directory exists") @@ -1752,12 +1744,7 @@ def run_doctor(args): else: check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") - # ========================================================================= - # Memory Provider (only check the active provider, if any) - # ========================================================================= - print() - print(color("◆ Memory Provider", Colors.CYAN, Colors.BOLD)) - + _section("Memory Provider") _active_memory_provider = "" try: import yaml as _yaml @@ -1782,8 +1769,12 @@ def run_doctor(args): elif not hcfg.enabled: check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)") elif not (hcfg.api_key or hcfg.base_url): - check_fail("Honcho API key or base URL not set", "run: hermes memory setup") - issues.append("No Honcho API key — run 'hermes memory setup'") + _fail_and_issue( + "Honcho API key or base URL not set", + "run: hermes memory setup", + "No Honcho API key — run 'hermes memory setup'", + issues, + ) else: from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client reset_honcho_client() @@ -1794,11 +1785,14 @@ def run_doctor(args): f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}", ) except Exception as _e: - check_fail("Honcho connection failed", str(_e)) - issues.append(f"Honcho unreachable: {_e}") + _fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues) except ImportError: - check_fail("honcho-ai not installed", "pip install honcho-ai") - issues.append("Honcho is set as memory provider but honcho-ai is not installed") + _fail_and_issue( + "honcho-ai not installed", + "pip install honcho-ai", + "Honcho is set as memory provider but honcho-ai is not installed", + issues, + ) except Exception as _e: check_warn("Honcho check failed", str(_e)) elif _active_memory_provider == "mem0": @@ -1810,11 +1804,19 @@ def run_doctor(args): check_ok("Mem0 API key configured") check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}") else: - check_fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)") - issues.append("Mem0 is set as memory provider but API key is missing") + _fail_and_issue( + "Mem0 API key not set", + "(set MEM0_API_KEY in .env or run hermes memory setup)", + "Mem0 is set as memory provider but API key is missing", + issues, + ) except ImportError: - check_fail("Mem0 plugin not loadable", "pip install mem0ai") - issues.append("Mem0 is set as memory provider but mem0ai is not installed") + _fail_and_issue( + "Mem0 plugin not loadable", + "pip install mem0ai", + "Mem0 is set as memory provider but mem0ai is not installed", + issues, + ) except Exception as _e: check_warn("Mem0 check failed", str(_e)) else: @@ -1831,17 +1833,13 @@ def run_doctor(args): except Exception as _e: check_warn(f"{_active_memory_provider} check failed", str(_e)) - # ========================================================================= - # Profiles - # ========================================================================= try: from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists import re as _re named_profiles = [p for p in list_profiles() if not p.is_default] if named_profiles: - print() - print(color("◆ Profiles", Colors.CYAN, Colors.BOLD)) + _section("Profiles") check_ok(f"{len(named_profiles)} profile(s) found") wrapper_dir = _get_wrapper_dir() for p in named_profiles: @@ -1878,9 +1876,6 @@ def run_doctor(args): except Exception: pass - # ========================================================================= - # Summary - # ========================================================================= print() remaining_issues = issues + manual_issues if should_fix and fixed_count > 0: