/ hermes_cli / doctor.py
doctor.py
   1  """
   2  Doctor command for hermes CLI.
   3  
   4  Diagnoses issues with Hermes Agent setup.
   5  """
   6  
   7  import os
   8  import sys
   9  import subprocess
  10  import shutil
  11  import importlib.util
  12  from pathlib import Path
  13  
  14  from hermes_cli.config import get_project_root, get_hermes_home, get_env_path
  15  from hermes_constants import display_hermes_home
  16  
  17  PROJECT_ROOT = get_project_root()
  18  HERMES_HOME = get_hermes_home()
  19  _DHH = display_hermes_home()  # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)
  20  
  21  # Load environment variables from ~/.hermes/.env so API key checks work
  22  from dotenv import load_dotenv
  23  _env_path = get_env_path()
  24  if _env_path.exists():
  25      try:
  26          load_dotenv(_env_path, encoding="utf-8")
  27      except UnicodeDecodeError:
  28          load_dotenv(_env_path, encoding="latin-1")
  29  # Also try project .env as dev fallback
  30  load_dotenv(PROJECT_ROOT / ".env", override=False, encoding="utf-8")
  31  
  32  from hermes_cli.colors import Colors, color
  33  from hermes_cli.models import _HERMES_USER_AGENT
  34  from hermes_cli.vercel_auth import describe_vercel_auth
  35  from hermes_constants import OPENROUTER_MODELS_URL
  36  from utils import base_url_host_matches
  37  
  38  
  39  _PROVIDER_ENV_HINTS = (
  40      "OPENROUTER_API_KEY",
  41      "OPENAI_API_KEY",
  42      "ANTHROPIC_API_KEY",
  43      "ANTHROPIC_TOKEN",
  44      "OPENAI_BASE_URL",
  45      "NOUS_API_KEY",
  46      "GLM_API_KEY",
  47      "ZAI_API_KEY",
  48      "Z_AI_API_KEY",
  49      "KIMI_API_KEY",
  50      "KIMI_CN_API_KEY",
  51      "GMI_API_KEY",
  52      "MINIMAX_API_KEY",
  53      "MINIMAX_CN_API_KEY",
  54      "KILOCODE_API_KEY",
  55      "DEEPSEEK_API_KEY",
  56      "DASHSCOPE_API_KEY",
  57      "HF_TOKEN",
  58      "AI_GATEWAY_API_KEY",
  59      "OPENCODE_ZEN_API_KEY",
  60      "OPENCODE_GO_API_KEY",
  61      "XIAOMI_API_KEY",
  62      "TOKENHUB_API_KEY",
  63  )
  64  
  65  
  66  from hermes_constants import is_termux as _is_termux
  67  
  68  
  69  def _python_install_cmd() -> str:
  70      return "python -m pip install" if _is_termux() else "uv pip install"
  71  
  72  
  73  def _system_package_install_cmd(pkg: str) -> str:
  74      if _is_termux():
  75          return f"pkg install {pkg}"
  76      if sys.platform == "darwin":
  77          return f"brew install {pkg}"
  78      return f"sudo apt install {pkg}"
  79  
  80  
  81  def _safe_which(cmd: str) -> str | None:
  82      """shutil.which wrapper resilient to platform monkeypatching in tests."""
  83      try:
  84          return shutil.which(cmd)
  85      except Exception:
  86          return None
  87  
  88  
  89  def _termux_browser_setup_steps(node_installed: bool) -> list[str]:
  90      steps: list[str] = []
  91      step = 1
  92      if not node_installed:
  93          steps.append(f"{step}) pkg install nodejs")
  94          step += 1
  95      steps.append(f"{step}) npm install -g agent-browser")
  96      steps.append(f"{step + 1}) agent-browser install")
  97      return steps
  98  
  99  
 100  def _has_provider_env_config(content: str) -> bool:
 101      """Return True when ~/.hermes/.env contains provider auth/base URL settings."""
 102      return any(key in content for key in _PROVIDER_ENV_HINTS)
 103  
 104  
 105  def _honcho_is_configured_for_doctor() -> bool:
 106      """Return True when Honcho is configured, even if this process has no active session."""
 107      try:
 108          from plugins.memory.honcho.client import HonchoClientConfig
 109  
 110          cfg = HonchoClientConfig.from_global_config()
 111          return bool(cfg.enabled and (cfg.api_key or cfg.base_url))
 112      except Exception:
 113          return False
 114  
 115  
 116  def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
 117      """Adjust runtime-gated tool availability for doctor diagnostics."""
 118      if not _honcho_is_configured_for_doctor():
 119          return available, unavailable
 120  
 121      updated_available = list(available)
 122      updated_unavailable = []
 123      for item in unavailable:
 124          if item.get("name") == "honcho":
 125              if "honcho" not in updated_available:
 126                  updated_available.append("honcho")
 127              continue
 128          updated_unavailable.append(item)
 129      return updated_available, updated_unavailable
 130  
 131  
 132  def check_ok(text: str, detail: str = ""):
 133      print(f"  {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
 134  
 135  def check_warn(text: str, detail: str = ""):
 136      print(f"  {color('⚠', Colors.YELLOW)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
 137  
 138  def check_fail(text: str, detail: str = ""):
 139      print(f"  {color('✗', Colors.RED)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else ""))
 140  
 141  def check_info(text: str):
 142      print(f"    {color('→', Colors.CYAN)} {text}")
 143  
 144  
 145  def _check_gateway_service_linger(issues: list[str]) -> None:
 146      """Warn when a systemd user gateway service will stop after logout."""
 147      try:
 148          from hermes_cli.gateway import (
 149              get_systemd_linger_status,
 150              get_systemd_unit_path,
 151              is_linux,
 152          )
 153      except Exception as e:
 154          check_warn("Gateway service linger", f"(could not import gateway helpers: {e})")
 155          return
 156  
 157      if not is_linux():
 158          return
 159  
 160      unit_path = get_systemd_unit_path()
 161      if not unit_path.exists():
 162          return
 163  
 164      print()
 165      print(color("◆ Gateway Service", Colors.CYAN, Colors.BOLD))
 166  
 167      linger_enabled, linger_detail = get_systemd_linger_status()
 168      if linger_enabled is True:
 169          check_ok("Systemd linger enabled", "(gateway service survives logout)")
 170      elif linger_enabled is False:
 171          check_warn("Systemd linger disabled", "(gateway may stop after logout)")
 172          check_info("Run: sudo loginctl enable-linger $USER")
 173          issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
 174      else:
 175          check_warn("Could not verify systemd linger", f"({linger_detail})")
 176  
 177  
 178  def run_doctor(args):
 179      """Run diagnostic checks."""
 180      should_fix = getattr(args, 'fix', False)
 181  
 182      # Doctor runs from the interactive CLI, so CLI-gated tool availability
 183      # checks (like cronjob management) should see the same context as `hermes`.
 184      os.environ.setdefault("HERMES_INTERACTIVE", "1")
 185      
 186      issues = []
 187      manual_issues = []  # issues that can't be auto-fixed
 188      fixed_count = 0
 189      
 190      print()
 191      print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN))
 192      print(color("│                 🩺 Hermes Doctor                        │", Colors.CYAN))
 193      print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
 194      
 195      # =========================================================================
 196      # Check: Python version
 197      # =========================================================================
 198      print()
 199      print(color("◆ Python Environment", Colors.CYAN, Colors.BOLD))
 200      
 201      py_version = sys.version_info
 202      if py_version >= (3, 11):
 203          check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}")
 204      elif py_version >= (3, 10):
 205          check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}")
 206          check_warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
 207      elif py_version >= (3, 8):
 208          check_warn(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ recommended)")
 209      else:
 210          check_fail(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", "(3.10+ required)")
 211          issues.append("Upgrade Python to 3.10+")
 212      
 213      # Check if in virtual environment
 214      in_venv = sys.prefix != sys.base_prefix
 215      if in_venv:
 216          check_ok("Virtual environment active")
 217      else:
 218          check_warn("Not in virtual environment", "(recommended)")
 219      
 220      # =========================================================================
 221      # Check: Required packages
 222      # =========================================================================
 223      print()
 224      print(color("◆ Required Packages", Colors.CYAN, Colors.BOLD))
 225      
 226      required_packages = [
 227          ("openai", "OpenAI SDK"),
 228          ("rich", "Rich (terminal UI)"),
 229          ("dotenv", "python-dotenv"),
 230          ("yaml", "PyYAML"),
 231          ("httpx", "HTTPX"),
 232      ]
 233      
 234      optional_packages = [
 235          ("croniter", "Croniter (cron expressions)"),
 236          ("telegram", "python-telegram-bot"),
 237          ("discord", "discord.py"),
 238      ]
 239      
 240      for module, name in required_packages:
 241          try:
 242              __import__(module)
 243              check_ok(name)
 244          except ImportError:
 245              check_fail(name, "(missing)")
 246              issues.append(f"Install {name}: {_python_install_cmd()} {module}")
 247      
 248      for module, name in optional_packages:
 249          try:
 250              __import__(module)
 251              check_ok(name, "(optional)")
 252          except ImportError:
 253              check_warn(name, "(optional, not installed)")
 254      
 255      # =========================================================================
 256      # Check: Configuration files
 257      # =========================================================================
 258      print()
 259      print(color("◆ Configuration Files", Colors.CYAN, Colors.BOLD))
 260      
 261      # Check ~/.hermes/.env (primary location for user config)
 262      env_path = HERMES_HOME / '.env'
 263      if env_path.exists():
 264          check_ok(f"{_DHH}/.env file exists")
 265          
 266          # Check for common issues. Pin encoding to UTF-8 because .env files are
 267          # written as UTF-8 everywhere in the codebase, while Path.read_text()
 268          # defaults to the system locale — which crashes on non-UTF-8 Windows
 269          # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.
 270          content = env_path.read_text(encoding="utf-8")
 271          if _has_provider_env_config(content):
 272              check_ok("API key or custom endpoint configured")
 273          else:
 274              check_warn(f"No API key found in {_DHH}/.env")
 275              issues.append("Run 'hermes setup' to configure API keys")
 276      else:
 277          # Also check project root as fallback
 278          fallback_env = PROJECT_ROOT / '.env'
 279          if fallback_env.exists():
 280              check_ok(".env file exists (in project directory)")
 281          else:
 282              check_fail(f"{_DHH}/.env file missing")
 283              if should_fix:
 284                  env_path.parent.mkdir(parents=True, exist_ok=True)
 285                  env_path.touch()
 286                  check_ok(f"Created empty {_DHH}/.env")
 287                  check_info("Run 'hermes setup' to configure API keys")
 288                  fixed_count += 1
 289              else:
 290                  check_info("Run 'hermes setup' to create one")
 291                  issues.append("Run 'hermes setup' to create .env")
 292      
 293      # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)
 294      config_path = HERMES_HOME / 'config.yaml'
 295      if config_path.exists():
 296          check_ok(f"{_DHH}/config.yaml exists")
 297  
 298          # Validate model.provider and model.default values
 299          try:
 300              import yaml as _yaml
 301              cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
 302              model_section = cfg.get("model") or {}
 303              provider_raw = (model_section.get("provider") or "").strip()
 304              provider = provider_raw.lower()
 305              default_model = (model_section.get("default") or model_section.get("model") or "").strip()
 306  
 307              known_providers: set = set()
 308              try:
 309                  from hermes_cli.auth import (
 310                      PROVIDER_REGISTRY,
 311                      resolve_provider as _resolve_auth_provider,
 312                  )
 313                  known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
 314              except Exception:
 315                  _resolve_auth_provider = None
 316                  pass
 317              try:
 318                  from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers
 319                  from hermes_cli.providers import (
 320                      normalize_provider as _normalize_catalog_provider,
 321                      resolve_provider_full as _resolve_provider_full,
 322                  )
 323              except Exception:
 324                  _compatible_custom_providers = None
 325                  _normalize_catalog_provider = None
 326                  _resolve_provider_full = None
 327  
 328              custom_providers = []
 329              if _compatible_custom_providers is not None:
 330                  try:
 331                      custom_providers = _compatible_custom_providers(cfg)
 332                  except Exception:
 333                      custom_providers = []
 334  
 335              user_providers = cfg.get("providers")
 336              if isinstance(user_providers, dict):
 337                  known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())
 338              for entry in custom_providers:
 339                  if not isinstance(entry, dict):
 340                      continue
 341                  name = str(entry.get("name") or "").strip()
 342                  if name:
 343                      known_providers.add("custom:" + name.lower().replace(" ", "-"))
 344  
 345              valid_provider_ids = set(known_providers)
 346              provider_ids_to_accept = {provider} if provider else set()
 347              if _normalize_catalog_provider is not None:
 348                  for known_provider in known_providers:
 349                      try:
 350                          valid_provider_ids.add(_normalize_catalog_provider(known_provider))
 351                      except Exception:
 352                          continue
 353  
 354              runtime_provider = provider
 355              if (
 356                  provider
 357                  and _resolve_auth_provider is not None
 358                  and provider not in ("auto", "custom")
 359              ):
 360                  try:
 361                      runtime_provider = _resolve_auth_provider(provider)
 362                      provider_ids_to_accept.add(runtime_provider)
 363                  except Exception:
 364                      runtime_provider = provider
 365  
 366              catalog_provider = provider
 367              if (
 368                  provider
 369                  and _resolve_provider_full is not None
 370                  and provider not in ("auto", "custom")
 371              ):
 372                  provider_def = _resolve_provider_full(provider, user_providers, custom_providers)
 373                  catalog_provider = provider_def.id if provider_def is not None else None
 374                  if catalog_provider is not None:
 375                      provider_ids_to_accept.add(catalog_provider)
 376  
 377              if provider and provider != "auto":
 378                  if catalog_provider is None or (
 379                      known_providers
 380                      and not (provider_ids_to_accept & valid_provider_ids)
 381                  ):
 382                      known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
 383                      check_fail(
 384                          f"model.provider '{provider_raw}' is not a recognised provider",
 385                          f"(known: {known_list})",
 386                      )
 387                      issues.append(
 388                          f"model.provider '{provider_raw}' is unknown. "
 389                          f"Valid providers: {known_list}. "
 390                          f"Fix: run 'hermes config set model.provider <valid_provider>'"
 391                      )
 392  
 393              # Warn if model is set to a provider-prefixed name on a provider that doesn't use them
 394              provider_for_policy = runtime_provider or catalog_provider
 395              providers_accepting_vendor_slugs = {
 396                  "openrouter",
 397                  "custom",
 398                  "auto",
 399                  "ai-gateway",
 400                  "kilocode",
 401                  "opencode-zen",
 402                  "huggingface",
 403                  "lmstudio",
 404                  "nous",
 405              }
 406              if (
 407                  default_model
 408                  and "/" in default_model
 409                  and provider_for_policy
 410                  and provider_for_policy not in providers_accepting_vendor_slugs
 411              ):
 412                  check_warn(
 413                      f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
 414                      "(vendor-prefixed slugs belong to aggregators like openrouter)",
 415                  )
 416                  issues.append(
 417                      f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. "
 418                      "Either set model.provider to 'openrouter', or drop the vendor prefix."
 419                  )
 420  
 421              # Check credentials for the configured provider.
 422              # Limit to API-key providers in PROVIDER_REGISTRY — other provider
 423              # types (OAuth, SDK, openrouter/anthropic/custom/auto) have their
 424              # own env-var checks elsewhere in doctor, and get_auth_status()
 425              # returns a bare {logged_in: False} for anything it doesn't
 426              # explicitly dispatch, which would produce false positives.
 427              if runtime_provider and runtime_provider not in ("auto", "custom", "openrouter"):
 428                  try:
 429                      from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
 430                      pconfig = PROVIDER_REGISTRY.get(runtime_provider)
 431                      if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
 432                          status = get_auth_status(runtime_provider) or {}
 433                          configured = bool(
 434                              status.get("configured")
 435                              or status.get("logged_in")
 436                              or status.get("api_key")
 437                          )
 438                          if not configured:
 439                              check_fail(
 440                                  f"model.provider '{runtime_provider}' is set but no API key is configured",
 441                                  "(check ~/.hermes/.env or run 'hermes setup')",
 442                              )
 443                              issues.append(
 444                                  f"No credentials found for provider '{runtime_provider}'. "
 445                                  f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
 446                                  f"or switch providers with 'hermes config set model.provider <name>'"
 447                              )
 448                  except Exception:
 449                      pass
 450  
 451          except Exception as e:
 452              check_warn("Could not validate model/provider config", f"({e})")
 453      else:
 454          fallback_config = PROJECT_ROOT / 'cli-config.yaml'
 455          if fallback_config.exists():
 456              check_ok("cli-config.yaml exists (in project directory)")
 457          else:
 458              example_config = PROJECT_ROOT / 'cli-config.yaml.example'
 459              if should_fix and example_config.exists():
 460                  config_path.parent.mkdir(parents=True, exist_ok=True)
 461                  shutil.copy2(str(example_config), str(config_path))
 462                  check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
 463                  fixed_count += 1
 464              elif should_fix:
 465                  check_warn("config.yaml not found and no example to copy from")
 466                  manual_issues.append(f"Create {_DHH}/config.yaml manually")
 467              else:
 468                  check_warn("config.yaml not found", "(using defaults)")
 469  
 470      # Check config version and stale keys
 471      config_path = HERMES_HOME / 'config.yaml'
 472      if config_path.exists():
 473          try:
 474              from hermes_cli.config import check_config_version, migrate_config
 475              current_ver, latest_ver = check_config_version()
 476              if current_ver < latest_ver:
 477                  check_warn(
 478                      f"Config version outdated (v{current_ver} → v{latest_ver})",
 479                      "(new settings available)"
 480                  )
 481                  if should_fix:
 482                      try:
 483                          migrate_config(interactive=False, quiet=False)
 484                          check_ok("Config migrated to latest version")
 485                          fixed_count += 1
 486                      except Exception as mig_err:
 487                          check_warn(f"Auto-migration failed: {mig_err}")
 488                          issues.append("Run 'hermes setup' to migrate config")
 489                  else:
 490                      issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config")
 491              else:
 492                  check_ok(f"Config version up to date (v{current_ver})")
 493          except Exception:
 494              pass
 495  
 496          # Detect stale root-level model keys (known bug source — PR #4329)
 497          try:
 498              import yaml
 499              with open(config_path) as f:
 500                  raw_config = yaml.safe_load(f) or {}
 501              stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)]
 502              if stale_root_keys:
 503                  check_warn(
 504                      f"Stale root-level config keys: {', '.join(stale_root_keys)}",
 505                      "(should be under 'model:' section)"
 506                  )
 507                  if should_fix:
 508                      model_section = raw_config.setdefault("model", {})
 509                      for k in stale_root_keys:
 510                          if not model_section.get(k):
 511                              model_section[k] = raw_config.pop(k)
 512                          else:
 513                              raw_config.pop(k)
 514                      from utils import atomic_yaml_write
 515                      atomic_yaml_write(config_path, raw_config)
 516                      check_ok("Migrated stale root-level keys into model section")
 517                      fixed_count += 1
 518                  else:
 519                      issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'")
 520          except Exception:
 521              pass
 522  
 523          # Validate config structure (catches malformed custom_providers, etc.)
 524          try:
 525              from hermes_cli.config import validate_config_structure
 526              config_issues = validate_config_structure()
 527              if config_issues:
 528                  print()
 529                  print(color("◆ Config Structure", Colors.CYAN, Colors.BOLD))
 530                  for ci in config_issues:
 531                      if ci.severity == "error":
 532                          check_fail(ci.message)
 533                      else:
 534                          check_warn(ci.message)
 535                      # Show the hint indented
 536                      for hint_line in ci.hint.splitlines():
 537                          check_info(hint_line)
 538                      issues.append(ci.message)
 539          except Exception:
 540              pass
 541  
 542      # =========================================================================
 543      # Check: Auth providers
 544      # =========================================================================
 545      print()
 546      print(color("◆ Auth Providers", Colors.CYAN, Colors.BOLD))
 547  
 548      try:
 549          from hermes_cli.auth import (
 550              get_nous_auth_status,
 551              get_codex_auth_status,
 552              get_gemini_oauth_auth_status,
 553              get_minimax_oauth_auth_status,
 554          )
 555  
 556          nous_status = get_nous_auth_status()
 557          if nous_status.get("logged_in"):
 558              check_ok("Nous Portal auth", "(logged in)")
 559          else:
 560              check_warn("Nous Portal auth", "(not logged in)")
 561  
 562          codex_status = get_codex_auth_status()
 563          if codex_status.get("logged_in"):
 564              check_ok("OpenAI Codex auth", "(logged in)")
 565          else:
 566              check_warn("OpenAI Codex auth", "(not logged in)")
 567              if codex_status.get("error"):
 568                  check_info(codex_status["error"])
 569  
 570          gemini_status = get_gemini_oauth_auth_status()
 571          if gemini_status.get("logged_in"):
 572              email = gemini_status.get("email") or ""
 573              project = gemini_status.get("project_id") or ""
 574              pieces = []
 575              if email:
 576                  pieces.append(email)
 577              if project:
 578                  pieces.append(f"project={project}")
 579              suffix = f" ({', '.join(pieces)})" if pieces else ""
 580              check_ok("Google Gemini OAuth", f"(logged in{suffix})")
 581          else:
 582              check_warn("Google Gemini OAuth", "(not logged in)")
 583  
 584          minimax_status = get_minimax_oauth_auth_status()
 585          if minimax_status.get("logged_in"):
 586              region = minimax_status.get("region", "global")
 587              check_ok("MiniMax OAuth", f"(logged in, region={region})")
 588          else:
 589              check_warn("MiniMax OAuth", "(not logged in)")
 590      except Exception as e:
 591          check_warn("Auth provider status", f"(could not check: {e})")
 592  
 593      if _safe_which("codex"):
 594          check_ok("codex CLI")
 595      else:
 596          # Native OAuth uses Hermes' own device-code flow — the Codex CLI is
 597          # only needed if you want to import existing tokens from
 598          # ~/.codex/auth.json.  Downgrade to info so users running
 599          # `hermes auth openai-codex` aren't told they're missing something.
 600          check_info(
 601              "codex CLI not installed "
 602              "(optional — only required to import tokens from an existing Codex CLI login)"
 603          )
 604  
 605      # =========================================================================
 606      # Check: Directory structure
 607      # =========================================================================
 608      print()
 609      print(color("◆ Directory Structure", Colors.CYAN, Colors.BOLD))
 610      
 611      hermes_home = HERMES_HOME
 612      if hermes_home.exists():
 613          check_ok(f"{_DHH} directory exists")
 614      else:
 615          if should_fix:
 616              hermes_home.mkdir(parents=True, exist_ok=True)
 617              check_ok(f"Created {_DHH} directory")
 618              fixed_count += 1
 619          else:
 620              check_warn(f"{_DHH} not found", "(will be created on first use)")
 621      
 622      # Check expected subdirectories
 623      expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"]
 624      for subdir_name in expected_subdirs:
 625          subdir_path = hermes_home / subdir_name
 626          if subdir_path.exists():
 627              check_ok(f"{_DHH}/{subdir_name}/ exists")
 628          else:
 629              if should_fix:
 630                  subdir_path.mkdir(parents=True, exist_ok=True)
 631                  check_ok(f"Created {_DHH}/{subdir_name}/")
 632                  fixed_count += 1
 633              else:
 634                  check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)")
 635      
 636      # Check for SOUL.md persona file
 637      soul_path = hermes_home / "SOUL.md"
 638      if soul_path.exists():
 639          content = soul_path.read_text(encoding="utf-8").strip()
 640          # Check if it's just the template comments (no real content)
 641          lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
 642          if lines:
 643              check_ok(f"{_DHH}/SOUL.md exists (persona configured)")
 644          else:
 645              check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
 646      else:
 647          check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
 648          if should_fix:
 649              soul_path.parent.mkdir(parents=True, exist_ok=True)
 650              soul_path.write_text(
 651                  "# Hermes Agent Persona\n\n"
 652                  "<!-- Edit this file to customize how Hermes communicates. -->\n\n"
 653                  "You are Hermes, a helpful AI assistant.\n",
 654                  encoding="utf-8",
 655              )
 656              check_ok(f"Created {_DHH}/SOUL.md with basic template")
 657              fixed_count += 1
 658      
 659      # Check memory directory
 660      memories_dir = hermes_home / "memories"
 661      if memories_dir.exists():
 662          check_ok(f"{_DHH}/memories/ directory exists")
 663          memory_file = memories_dir / "MEMORY.md"
 664          user_file = memories_dir / "USER.md"
 665          if memory_file.exists():
 666              size = len(memory_file.read_text(encoding="utf-8").strip())
 667              check_ok(f"MEMORY.md exists ({size} chars)")
 668          else:
 669              check_info("MEMORY.md not created yet (will be created when the agent first writes a memory)")
 670          if user_file.exists():
 671              size = len(user_file.read_text(encoding="utf-8").strip())
 672              check_ok(f"USER.md exists ({size} chars)")
 673          else:
 674              check_info("USER.md not created yet (will be created when the agent first writes a memory)")
 675      else:
 676          check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
 677          if should_fix:
 678              memories_dir.mkdir(parents=True, exist_ok=True)
 679              check_ok(f"Created {_DHH}/memories/")
 680              fixed_count += 1
 681      
 682      # Check SQLite session store
 683      state_db_path = hermes_home / "state.db"
 684      if state_db_path.exists():
 685          try:
 686              import sqlite3
 687              conn = sqlite3.connect(str(state_db_path))
 688              cursor = conn.execute("SELECT COUNT(*) FROM sessions")
 689              count = cursor.fetchone()[0]
 690              conn.close()
 691              check_ok(f"{_DHH}/state.db exists ({count} sessions)")
 692          except Exception as e:
 693              check_warn(f"{_DHH}/state.db exists but has issues: {e}")
 694      else:
 695          check_info(f"{_DHH}/state.db not created yet (will be created on first session)")
 696  
 697      # Check WAL file size (unbounded growth indicates missed checkpoints)
 698      wal_path = hermes_home / "state.db-wal"
 699      if wal_path.exists():
 700          try:
 701              wal_size = wal_path.stat().st_size
 702              if wal_size > 50 * 1024 * 1024:  # 50 MB
 703                  check_warn(
 704                      f"WAL file is large ({wal_size // (1024*1024)} MB)",
 705                      "(may indicate missed checkpoints)"
 706                  )
 707                  if should_fix:
 708                      import sqlite3
 709                      conn = sqlite3.connect(str(state_db_path))
 710                      conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
 711                      conn.close()
 712                      new_size = wal_path.stat().st_size if wal_path.exists() else 0
 713                      check_ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
 714                      fixed_count += 1
 715                  else:
 716                      issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint")
 717              elif wal_size > 10 * 1024 * 1024:  # 10 MB
 718                  check_info(f"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)")
 719          except Exception:
 720              pass
 721  
 722      _check_gateway_service_linger(issues)
 723  
 724      # =========================================================================
 725      # Check: Command installation (hermes bin symlink)
 726      # =========================================================================
 727      if sys.platform != "win32":
 728          print()
 729          print(color("◆ Command Installation", Colors.CYAN, Colors.BOLD))
 730  
 731          # Determine the venv entry point location
 732          _venv_bin = None
 733          for _venv_name in ("venv", ".venv"):
 734              _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes"
 735              if _candidate.exists():
 736                  _venv_bin = _candidate
 737                  break
 738  
 739          # Determine the expected command link directory (mirrors install.sh logic)
 740          _prefix = os.environ.get("PREFIX", "")
 741          _is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix
 742          if _is_termux_env and _prefix:
 743              _cmd_link_dir = Path(_prefix) / "bin"
 744              _cmd_link_display = "$PREFIX/bin"
 745          else:
 746              _cmd_link_dir = Path.home() / ".local" / "bin"
 747              _cmd_link_display = "~/.local/bin"
 748          _cmd_link = _cmd_link_dir / "hermes"
 749  
 750          if _venv_bin is None:
 751              check_warn(
 752                  "Venv entry point not found",
 753                  "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')"
 754              )
 755              manual_issues.append(
 756                  f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'"
 757              )
 758          else:
 759              check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})")
 760  
 761              # Check the symlink at the command link location
 762              if _cmd_link.is_symlink():
 763                  _target = _cmd_link.resolve()
 764                  _expected = _venv_bin.resolve()
 765                  if _target == _expected:
 766                      check_ok(f"{_cmd_link_display}/hermes → correct target")
 767                  else:
 768                      check_warn(
 769                          f"{_cmd_link_display}/hermes points to wrong target",
 770                          f"(→ {_target}, expected → {_expected})"
 771                      )
 772                      if should_fix:
 773                          _cmd_link.unlink()
 774                          _cmd_link.symlink_to(_venv_bin)
 775                          check_ok(f"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}")
 776                          fixed_count += 1
 777                      else:
 778                          issues.append(f"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'")
 779              elif _cmd_link.exists():
 780                  # It's a regular file, not a symlink — possibly a wrapper script
 781                  check_ok(f"{_cmd_link_display}/hermes exists (non-symlink)")
 782              else:
 783                  check_fail(
 784                      f"{_cmd_link_display}/hermes not found",
 785                      "(hermes command may not work outside the venv)"
 786                  )
 787                  if should_fix:
 788                      _cmd_link_dir.mkdir(parents=True, exist_ok=True)
 789                      _cmd_link.symlink_to(_venv_bin)
 790                      check_ok(f"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}")
 791                      fixed_count += 1
 792  
 793                      # Check if the link dir is on PATH
 794                      _path_dirs = os.environ.get("PATH", "").split(os.pathsep)
 795                      if str(_cmd_link_dir) not in _path_dirs:
 796                          check_warn(
 797                              f"{_cmd_link_display} is not on your PATH",
 798                              "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")"
 799                          )
 800                          manual_issues.append(f"Add {_cmd_link_display} to your PATH")
 801                  else:
 802                      issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'")
 803  
 804      # =========================================================================
 805      # Check: External tools
 806      # =========================================================================
 807      print()
 808      print(color("◆ External Tools", Colors.CYAN, Colors.BOLD))
 809      
 810      # Git
 811      if _safe_which("git"):
 812          check_ok("git")
 813      else:
 814          check_warn("git not found", "(optional)")
 815      
 816      # ripgrep (optional, for faster file search)
 817      if _safe_which("rg"):
 818          check_ok("ripgrep (rg)", "(faster file search)")
 819      else:
 820          check_warn("ripgrep (rg) not found", "(file search uses grep fallback)")
 821          check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}")
 822      
 823      # Docker (optional)
 824      terminal_env = os.getenv("TERMINAL_ENV", "local")
 825      if terminal_env == "docker":
 826          if _safe_which("docker"):
 827              # Check if docker daemon is running
 828              try:
 829                  result = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
 830              except subprocess.TimeoutExpired:
 831                  result = None
 832              if result is not None and result.returncode == 0:
 833                  check_ok("docker", "(daemon running)")
 834              else:
 835                  check_fail("docker daemon not running")
 836                  issues.append("Start Docker daemon")
 837          else:
 838              check_fail("docker not found", "(required for TERMINAL_ENV=docker)")
 839              issues.append("Install Docker or change TERMINAL_ENV")
 840      else:
 841          if _safe_which("docker"):
 842              check_ok("docker", "(optional)")
 843          else:
 844              if _is_termux():
 845                  check_info("Docker backend is not available inside Termux (expected on Android)")
 846              else:
 847                  check_warn("docker not found", "(optional)")
 848      
 849      # SSH (if using ssh backend)
 850      if terminal_env == "ssh":
 851          ssh_host = os.getenv("TERMINAL_SSH_HOST")
 852          if ssh_host:
 853              # Try to connect
 854              try:
 855                  result = subprocess.run(
 856                      ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", ssh_host, "echo ok"],
 857                      capture_output=True,
 858                      text=True,
 859                      timeout=15
 860                  )
 861              except subprocess.TimeoutExpired:
 862                  result = None
 863              if result is not None and result.returncode == 0:
 864                  check_ok(f"SSH connection to {ssh_host}")
 865              else:
 866                  check_fail(f"SSH connection to {ssh_host}")
 867                  issues.append(f"Check SSH configuration for {ssh_host}")
 868          else:
 869              check_fail("TERMINAL_SSH_HOST not set", "(required for TERMINAL_ENV=ssh)")
 870              issues.append("Set TERMINAL_SSH_HOST in .env")
 871      
 872      # Daytona (if using daytona backend)
 873      if terminal_env == "daytona":
 874          daytona_key = os.getenv("DAYTONA_API_KEY")
 875          if daytona_key:
 876              check_ok("Daytona API key", "(configured)")
 877          else:
 878              check_fail("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)")
 879              issues.append("Set DAYTONA_API_KEY environment variable")
 880          try:
 881              from daytona import Daytona  # noqa: F401 — SDK presence check
 882              check_ok("daytona SDK", "(installed)")
 883          except ImportError:
 884              check_fail("daytona SDK not installed", "(pip install daytona)")
 885              issues.append("Install daytona SDK: pip install daytona")
 886  
 887      # Vercel Sandbox (if using vercel_sandbox backend)
 888      if terminal_env == "vercel_sandbox":
 889          runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
 890          from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
 891          if runtime in _SUPPORTED_VERCEL_RUNTIMES:
 892              check_ok("Vercel runtime", f"({runtime})")
 893          else:
 894              supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
 895              check_fail("Vercel runtime unsupported", f"({runtime}; use {supported})")
 896              issues.append(f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}")
 897  
 898          disk = os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip()
 899          if disk in ("", "0", "51200"):
 900              check_ok("Vercel disk setting", "(uses platform default)")
 901          else:
 902              check_fail("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)")
 903              issues.append("Vercel Sandbox does not support custom container_disk; use the shared default 51200")
 904  
 905          if importlib.util.find_spec("vercel") is not None:
 906              check_ok("vercel SDK", "(installed)")
 907          else:
 908              check_fail("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')")
 909              issues.append("Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'")
 910  
 911          auth_status = describe_vercel_auth()
 912          if auth_status.ok:
 913              check_ok("Vercel auth", f"({auth_status.label})")
 914          elif auth_status.label.startswith("partial"):
 915              check_fail("Vercel auth incomplete", f"({auth_status.label})")
 916              issues.append("Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together")
 917          else:
 918              check_fail("Vercel auth not configured", f"({auth_status.label})")
 919              issues.append(
 920                  "Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID"
 921              )
 922          for line in auth_status.detail_lines:
 923              check_info(f"Vercel auth {line}")
 924  
 925          persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in ("1", "true", "yes", "on")
 926          if persistent:
 927              check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
 928          else:
 929              check_info("Vercel persistence: ephemeral filesystem")
 930  
 931      # Node.js + agent-browser (for browser automation tools)
 932      if _safe_which("node"):
 933          check_ok("Node.js")
 934          # Check if agent-browser is installed
 935          agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser"
 936          if agent_browser_path.exists():
 937              check_ok("agent-browser (Node.js)", "(browser automation)")
 938          elif shutil.which("agent-browser"):
 939              check_ok("agent-browser", "(browser automation)")
 940          else:
 941              if _is_termux():
 942                  check_info("agent-browser is not installed (expected in the tested Termux path)")
 943                  check_info("Install it manually later with: npm install -g agent-browser && agent-browser install")
 944                  check_info("Termux browser setup:")
 945                  for step in _termux_browser_setup_steps(node_installed=True):
 946                      check_info(step)
 947              else:
 948                  check_warn("agent-browser not installed", "(run: npm install)")
 949      else:
 950          if _is_termux():
 951              check_info("Node.js not found (browser tools are optional in the tested Termux path)")
 952              check_info("Install Node.js on Termux with: pkg install nodejs")
 953              check_info("Termux browser setup:")
 954              for step in _termux_browser_setup_steps(node_installed=False):
 955                  check_info(step)
 956          else:
 957              check_warn("Node.js not found", "(optional, needed for browser tools)")
 958      
 959      # npm audit for all Node.js packages
 960      if _safe_which("npm"):
 961          npm_dirs = [
 962              (PROJECT_ROOT, "Browser tools (agent-browser)"),
 963              (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge"),
 964          ]
 965          for npm_dir, label in npm_dirs:
 966              if not (npm_dir / "node_modules").exists():
 967                  continue
 968              try:
 969                  audit_result = subprocess.run(
 970                      ["npm", "audit", "--json"],
 971                      cwd=str(npm_dir),
 972                      capture_output=True, text=True, timeout=30,
 973                  )
 974                  import json as _json
 975                  audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}
 976                  vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {})
 977                  critical = vuln_count.get("critical", 0)
 978                  high = vuln_count.get("high", 0)
 979                  moderate = vuln_count.get("moderate", 0)
 980                  total = critical + high + moderate
 981                  if total == 0:
 982                      check_ok(f"{label} deps", "(no known vulnerabilities)")
 983                  elif critical > 0 or high > 0:
 984                      check_warn(
 985                          f"{label} deps",
 986                          f"({critical} critical, {high} high, {moderate} moderate — run: cd {npm_dir} && npm audit fix)"
 987                      )
 988                      issues.append(f"{label} has {total} npm vulnerability(ies)")
 989                  else:
 990                      check_ok(f"{label} deps", f"({moderate} moderate vulnerability(ies))")
 991              except Exception:
 992                  pass
 993  
 994      # =========================================================================
 995      # Check: API connectivity
 996      # =========================================================================
 997      print()
 998      print(color("◆ API Connectivity", Colors.CYAN, Colors.BOLD))
 999      
1000      openrouter_key = os.getenv("OPENROUTER_API_KEY")
1001      if openrouter_key:
1002          print("  Checking OpenRouter API...", end="", flush=True)
1003          try:
1004              import httpx
1005              response = httpx.get(
1006                  OPENROUTER_MODELS_URL,
1007                  headers={"Authorization": f"Bearer {openrouter_key}"},
1008                  timeout=10
1009              )
1010              if response.status_code == 200:
1011                  print(f"\r  {color('✓', Colors.GREEN)} OpenRouter API                          ")
1012              elif response.status_code == 401:
1013                  print(f"\r  {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)}                ")
1014                  issues.append("Check OPENROUTER_API_KEY in .env")
1015              elif response.status_code == 402:
1016                  print(f"\r  {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}")
1017                  issues.append(
1018                      "OpenRouter account has insufficient credits. "
1019                      "Fix: run 'hermes config set model.provider <provider>' to switch providers, "
1020                      "or fund your OpenRouter account at https://openrouter.ai/settings/credits"
1021                  )
1022              elif response.status_code == 429:
1023                  print(f"\r  {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)}                ")
1024                  issues.append("OpenRouter rate limit hit — consider switching to a different provider or waiting")
1025              else:
1026                  print(f"\r  {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)}                ")
1027          except Exception as e:
1028              print(f"\r  {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)}                ")
1029              issues.append("Check network connectivity")
1030      else:
1031          check_warn("OpenRouter API", "(not configured)")
1032      
1033      from hermes_cli.auth import get_anthropic_key
1034      anthropic_key = get_anthropic_key()
1035      if anthropic_key:
1036          print("  Checking Anthropic API...", end="", flush=True)
1037          try:
1038              import httpx
1039              from agent.anthropic_adapter import (
1040                  _is_oauth_token,
1041                  _COMMON_BETAS,
1042                  _OAUTH_ONLY_BETAS,
1043                  _CONTEXT_1M_BETA,
1044              )
1045  
1046              headers = {"anthropic-version": "2023-06-01"}
1047              is_oauth = _is_oauth_token(anthropic_key)
1048              if is_oauth:
1049                  headers["Authorization"] = f"Bearer {anthropic_key}"
1050                  headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)
1051              else:
1052                  headers["x-api-key"] = anthropic_key
1053              response = httpx.get(
1054                  "https://api.anthropic.com/v1/models",
1055                  headers=headers,
1056                  timeout=10
1057              )
1058              # Reactive recovery: OAuth subscriptions that don't include 1M
1059              # context reject the request with 400 "long context beta is not
1060              # yet available for this subscription". Retry once with that
1061              # beta stripped so the doctor check doesn't falsely report the
1062              # Anthropic API as unreachable for those users.
1063              if (
1064                  is_oauth
1065                  and response.status_code == 400
1066                  and "long context beta" in response.text.lower()
1067                  and "not yet available" in response.text.lower()
1068              ):
1069                  headers["anthropic-beta"] = ",".join(
1070                      [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)
1071                  )
1072                  response = httpx.get(
1073                      "https://api.anthropic.com/v1/models",
1074                      headers=headers,
1075                      timeout=10,
1076                  )
1077              if response.status_code == 200:
1078                  print(f"\r  {color('✓', Colors.GREEN)} Anthropic API                           ")
1079              elif response.status_code == 401:
1080                  print(f"\r  {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)}                 ")
1081              else:
1082                  msg = "(couldn't verify)"
1083                  print(f"\r  {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)}                 ")
1084          except Exception as e:
1085              print(f"\r  {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)}                 ")
1086  
1087      # -- API-key providers --
1088      # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)
1089      # If supports_models_endpoint is False, we skip the health check and just show "configured"
1090      _apikey_providers = [
1091          ("Z.AI / GLM",      ("GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY"), "https://api.z.ai/api/paas/v4/models", "GLM_BASE_URL", True),
1092          ("Kimi / Moonshot",  ("KIMI_API_KEY",),                              "https://api.moonshot.ai/v1/models",   "KIMI_BASE_URL", True),
1093          ("StepFun Step Plan",   ("STEPFUN_API_KEY",),                           "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True),
1094          ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",),                    "https://api.moonshot.cn/v1/models",   None, True),
1095          ("Arcee AI",         ("ARCEEAI_API_KEY",),                            "https://api.arcee.ai/api/v1/models",  "ARCEE_BASE_URL", True),
1096          ("GMI Cloud",        ("GMI_API_KEY",),                                "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True),
1097          ("DeepSeek",         ("DEEPSEEK_API_KEY",),                           "https://api.deepseek.com/v1/models",  "DEEPSEEK_BASE_URL", True),
1098          ("Hugging Face",     ("HF_TOKEN",),                                   "https://router.huggingface.co/v1/models", "HF_BASE_URL", True),
1099          ("NVIDIA NIM",       ("NVIDIA_API_KEY",),                             "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True),
1100          ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",),                         "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True),
1101          # MiniMax global: /v1 endpoint supports /models.
1102          ("MiniMax",          ("MINIMAX_API_KEY",),                            "https://api.minimax.io/v1/models",    "MINIMAX_BASE_URL", True),
1103          # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).
1104          ("MiniMax (China)",  ("MINIMAX_CN_API_KEY",),                         "https://api.minimaxi.com/v1/models",  "MINIMAX_CN_BASE_URL", False),
1105          ("Vercel AI Gateway",       ("AI_GATEWAY_API_KEY",),                          "https://ai-gateway.vercel.sh/v1/models", "AI_GATEWAY_BASE_URL", True),
1106          ("Kilo Code",        ("KILOCODE_API_KEY",),                            "https://api.kilo.ai/api/gateway/models",  "KILOCODE_BASE_URL", True),
1107          ("OpenCode Zen",     ("OPENCODE_ZEN_API_KEY",),                        "https://opencode.ai/zen/v1/models",  "OPENCODE_ZEN_BASE_URL", True),
1108          # OpenCode Go has no shared /models endpoint; skip the health check.
1109          ("OpenCode Go",      ("OPENCODE_GO_API_KEY",),                         None,                                  "OPENCODE_GO_BASE_URL", False),
1110      ]
1111      for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:
1112          _key = ""
1113          for _ev in _env_vars:
1114              _key = os.getenv(_ev, "")
1115              if _key:
1116                  break
1117          if _key:
1118              _label = _pname.ljust(20)
1119              # Some providers (like MiniMax) don't support /models endpoint
1120              if not _supports_health_check:
1121                  print(f"  {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}")
1122                  continue
1123              print(f"  Checking {_pname} API...", end="", flush=True)
1124              try:
1125                  import httpx
1126                  _base = os.getenv(_base_env, "") if _base_env else ""
1127                  # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1
1128                  # (OpenAI-compat surface, which exposes /models for health check).
1129                  if not _base and _key.startswith("sk-kimi-"):
1130                      _base = "https://api.kimi.com/coding/v1"
1131                  # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding
1132                  # with no /v1) don't support /models.  Rewrite to the OpenAI-compat
1133                  # /v1 surface for health checks.
1134                  if _base and _base.rstrip("/").endswith("/anthropic"):
1135                      from agent.auxiliary_client import _to_openai_base_url
1136                      _base = _to_openai_base_url(_base)
1137                  if base_url_host_matches(_base, "api.kimi.com") and _base.rstrip("/").endswith("/coding"):
1138                      _base = _base.rstrip("/") + "/v1"
1139                  _url = (_base.rstrip("/") + "/models") if _base else _default_url
1140                  _headers = {
1141                      "Authorization": f"Bearer {_key}",
1142                      "User-Agent": _HERMES_USER_AGENT,
1143                  }
1144                  if base_url_host_matches(_base, "api.kimi.com"):
1145                      _headers["User-Agent"] = "claude-code/0.1.0"
1146                  _resp = httpx.get(
1147                      _url,
1148                      headers=_headers,
1149                      timeout=10,
1150                  )
1151                  if _resp.status_code == 200:
1152                      print(f"\r  {color('✓', Colors.GREEN)} {_label}                          ")
1153                  elif _resp.status_code == 401:
1154                      print(f"\r  {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)}           ")
1155                      issues.append(f"Check {_env_vars[0]} in .env")
1156                  else:
1157                      print(f"\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)}           ")
1158              except Exception as _e:
1159                  print(f"\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)}           ")
1160  
1161      # -- AWS Bedrock --
1162      # Bedrock uses the AWS SDK credential chain, not API keys.
1163      try:
1164          from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region
1165          if has_aws_credentials():
1166              _auth_var = resolve_aws_auth_env_var()
1167              _region = resolve_bedrock_region()
1168              _label = "AWS Bedrock".ljust(20)
1169              print(f"  Checking AWS Bedrock...", end="", flush=True)
1170              try:
1171                  import boto3
1172                  _br_client = boto3.client("bedrock", region_name=_region)
1173                  _br_resp = _br_client.list_foundation_models()
1174                  _model_count = len(_br_resp.get("modelSummaries", []))
1175                  print(f"\r  {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)}           ")
1176              except ImportError:
1177                  print(f"\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)}           ")
1178                  issues.append(f"Install boto3 for Bedrock: {sys.executable} -m pip install boto3")
1179              except Exception as _e:
1180                  _err_name = type(_e).__name__
1181                  print(f"\r  {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)}           ")
1182                  issues.append(f"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels")
1183      except ImportError:
1184          pass  # bedrock_adapter not available — skip silently
1185  
1186      # =========================================================================
1187      # Check: Submodules
1188      # =========================================================================
1189      print()
1190      print(color("◆ Submodules", Colors.CYAN, Colors.BOLD))
1191      
1192      # tinker-atropos (RL training backend)
1193      tinker_dir = PROJECT_ROOT / "tinker-atropos"
1194      if tinker_dir.exists() and (tinker_dir / "pyproject.toml").exists():
1195          if py_version >= (3, 11):
1196              try:
1197                  __import__("tinker_atropos")
1198                  check_ok("tinker-atropos", "(RL training backend)")
1199              except ImportError:
1200                  install_cmd = f"{_python_install_cmd()} -e ./tinker-atropos"
1201                  check_warn("tinker-atropos found but not installed", f"(run: {install_cmd})")
1202                  issues.append(f"Install tinker-atropos: {install_cmd}")
1203          else:
1204              check_warn("tinker-atropos requires Python 3.11+", f"(current: {py_version.major}.{py_version.minor})")
1205      else:
1206          check_warn("tinker-atropos not found", "(run: git submodule update --init --recursive)")
1207      
1208      # =========================================================================
1209      # Check: Tool Availability
1210      # =========================================================================
1211      print()
1212      print(color("◆ Tool Availability", Colors.CYAN, Colors.BOLD))
1213      
1214      try:
1215          # Add project root to path for imports
1216          sys.path.insert(0, str(PROJECT_ROOT))
1217          from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS
1218          
1219          available, unavailable = check_tool_availability()
1220          available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)
1221          
1222          for tid in available:
1223              info = TOOLSET_REQUIREMENTS.get(tid, {})
1224              check_ok(info.get("name", tid))
1225          
1226          for item in unavailable:
1227              env_vars = item.get("missing_vars") or item.get("env_vars") or []
1228              if env_vars:
1229                  vars_str = ", ".join(env_vars)
1230                  check_warn(item["name"], f"(missing {vars_str})")
1231              else:
1232                  check_warn(item["name"], "(system dependency not met)")
1233  
1234          # Count disabled tools with API key requirements
1235          api_disabled = [u for u in unavailable if (u.get("missing_vars") or u.get("env_vars"))]
1236          if api_disabled:
1237              issues.append("Run 'hermes setup' to configure missing API keys for full tool access")
1238      except Exception as e:
1239          check_warn("Could not check tool availability", f"({e})")
1240      
1241      # =========================================================================
1242      # Check: Skills Hub
1243      # =========================================================================
1244      print()
1245      print(color("◆ Skills Hub", Colors.CYAN, Colors.BOLD))
1246  
1247      hub_dir = HERMES_HOME / "skills" / ".hub"
1248      if hub_dir.exists():
1249          check_ok("Skills Hub directory exists")
1250          lock_file = hub_dir / "lock.json"
1251          if lock_file.exists():
1252              try:
1253                  import json
1254                  lock_data = json.loads(lock_file.read_text())
1255                  count = len(lock_data.get("installed", {}))
1256                  check_ok(f"Lock file OK ({count} hub-installed skill(s))")
1257              except Exception:
1258                  check_warn("Lock file", "(corrupted or unreadable)")
1259          quarantine = hub_dir / "quarantine"
1260          q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
1261          if q_count > 0:
1262              check_warn(f"{q_count} skill(s) in quarantine", "(pending review)")
1263      else:
1264          check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
1265  
1266      from hermes_cli.config import get_env_value
1267      github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
1268      if github_token:
1269          check_ok("GitHub token configured (authenticated API access)")
1270      else:
1271          check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
1272  
1273      # =========================================================================
1274      # Memory Provider (only check the active provider, if any)
1275      # =========================================================================
1276      print()
1277      print(color("◆ Memory Provider", Colors.CYAN, Colors.BOLD))
1278  
1279      _active_memory_provider = ""
1280      try:
1281          import yaml as _yaml
1282          _mem_cfg_path = HERMES_HOME / "config.yaml"
1283          if _mem_cfg_path.exists():
1284              with open(_mem_cfg_path) as _f:
1285                  _raw_cfg = _yaml.safe_load(_f) or {}
1286              _active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "")
1287      except Exception:
1288          pass
1289  
1290      if not _active_memory_provider:
1291          check_ok("Built-in memory active", "(no external provider configured — this is fine)")
1292      elif _active_memory_provider == "honcho":
1293          try:
1294              from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
1295              hcfg = HonchoClientConfig.from_global_config()
1296              _honcho_cfg_path = resolve_config_path()
1297  
1298              if not _honcho_cfg_path.exists():
1299                  check_warn("Honcho config not found", "run: hermes memory setup")
1300              elif not hcfg.enabled:
1301                  check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)")
1302              elif not (hcfg.api_key or hcfg.base_url):
1303                  check_fail("Honcho API key or base URL not set", "run: hermes memory setup")
1304                  issues.append("No Honcho API key — run 'hermes memory setup'")
1305              else:
1306                  from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
1307                  reset_honcho_client()
1308                  try:
1309                      get_honcho_client(hcfg)
1310                      check_ok(
1311                          "Honcho connected",
1312                          f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}",
1313                      )
1314                  except Exception as _e:
1315                      check_fail("Honcho connection failed", str(_e))
1316                      issues.append(f"Honcho unreachable: {_e}")
1317          except ImportError:
1318              check_fail("honcho-ai not installed", "pip install honcho-ai")
1319              issues.append("Honcho is set as memory provider but honcho-ai is not installed")
1320          except Exception as _e:
1321              check_warn("Honcho check failed", str(_e))
1322      elif _active_memory_provider == "mem0":
1323          try:
1324              from plugins.memory.mem0 import _load_config as _load_mem0_config
1325              mem0_cfg = _load_mem0_config()
1326              mem0_key = mem0_cfg.get("api_key", "")
1327              if mem0_key:
1328                  check_ok("Mem0 API key configured")
1329                  check_info(f"user_id={mem0_cfg.get('user_id', '?')}  agent_id={mem0_cfg.get('agent_id', '?')}")
1330              else:
1331                  check_fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)")
1332                  issues.append("Mem0 is set as memory provider but API key is missing")
1333          except ImportError:
1334              check_fail("Mem0 plugin not loadable", "pip install mem0ai")
1335              issues.append("Mem0 is set as memory provider but mem0ai is not installed")
1336          except Exception as _e:
1337              check_warn("Mem0 check failed", str(_e))
1338      else:
1339          # Generic check for other memory providers (openviking, hindsight, etc.)
1340          try:
1341              from plugins.memory import load_memory_provider
1342              _provider = load_memory_provider(_active_memory_provider)
1343              if _provider and _provider.is_available():
1344                  check_ok(f"{_active_memory_provider} provider active")
1345              elif _provider:
1346                  check_warn(f"{_active_memory_provider} configured but not available", "run: hermes memory status")
1347              else:
1348                  check_warn(f"{_active_memory_provider} plugin not found", "run: hermes memory setup")
1349          except Exception as _e:
1350              check_warn(f"{_active_memory_provider} check failed", str(_e))
1351  
1352      # =========================================================================
1353      # Profiles
1354      # =========================================================================
1355      try:
1356          from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
1357          import re as _re
1358  
1359          named_profiles = [p for p in list_profiles() if not p.is_default]
1360          if named_profiles:
1361              print()
1362              print(color("◆ Profiles", Colors.CYAN, Colors.BOLD))
1363              check_ok(f"{len(named_profiles)} profile(s) found")
1364              wrapper_dir = _get_wrapper_dir()
1365              for p in named_profiles:
1366                  parts = []
1367                  if p.gateway_running:
1368                      parts.append("gateway running")
1369                  if p.model:
1370                      parts.append(p.model[:30])
1371                  if not (p.path / "config.yaml").exists():
1372                      parts.append("⚠ missing config")
1373                  if not (p.path / ".env").exists():
1374                      parts.append("no .env")
1375                  wrapper = wrapper_dir / p.name
1376                  if not wrapper.exists():
1377                      parts.append("no alias")
1378                  status = ", ".join(parts) if parts else "configured"
1379                  check_ok(f"  {p.name}: {status}")
1380  
1381              # Check for orphan wrappers
1382              if wrapper_dir.is_dir():
1383                  for wrapper in wrapper_dir.iterdir():
1384                      if not wrapper.is_file():
1385                          continue
1386                      try:
1387                          content = wrapper.read_text()
1388                          if "hermes -p" in content:
1389                              _m = _re.search(r"hermes -p (\S+)", content)
1390                              if _m and not profile_exists(_m.group(1)):
1391                                  check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists")
1392                      except Exception:
1393                          pass
1394      except ImportError:
1395          pass
1396      except Exception:
1397          pass
1398  
1399      # =========================================================================
1400      # Summary
1401      # =========================================================================
1402      print()
1403      remaining_issues = issues + manual_issues
1404      if should_fix and fixed_count > 0:
1405          print(color("─" * 60, Colors.GREEN))
1406          print(color(f"  Fixed {fixed_count} issue(s).", Colors.GREEN, Colors.BOLD), end="")
1407          if remaining_issues:
1408              print(color(f" {len(remaining_issues)} issue(s) require manual intervention.", Colors.YELLOW, Colors.BOLD))
1409          else:
1410              print()
1411          print()
1412          if remaining_issues:
1413              for i, issue in enumerate(remaining_issues, 1):
1414                  print(f"  {i}. {issue}")
1415              print()
1416      elif remaining_issues:
1417          print(color("─" * 60, Colors.YELLOW))
1418          print(color(f"  Found {len(remaining_issues)} issue(s) to address:", Colors.YELLOW, Colors.BOLD))
1419          print()
1420          for i, issue in enumerate(remaining_issues, 1):
1421              print(f"  {i}. {issue}")
1422          print()
1423          if not should_fix:
1424              print(color("  Tip: run 'hermes doctor --fix' to auto-fix what's possible.", Colors.DIM))
1425      else:
1426          print(color("─" * 60, Colors.GREEN))
1427          print(color("  All checks passed! 🎉", Colors.GREEN, Colors.BOLD))
1428      
1429      print()