/ 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()