{"repo": "ashneil12/vanilla-hermes-agent-canary", "files": {"hermes_cli/doctor.py": {"148f82ed297ad19fd57efa72b9b725989bdf6db7": {"t": 1785745445.5211024, "n": 117757, "h": "374c0843393a3a7a", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates(should_fix: bool = False, issues: \"list | None\" = None) -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n\n With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g.\n after a brew Python upgrade rebuilt the venv, #29866) is repaired by\n force-reinstalling certifi into THIS interpreter's environment and\n re-verifying.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n return\n except SSLConfigurationError as e:\n first_error = str(e)\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n if not should_fix:\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n if issues is not None:\n issues.append(\n \"Repair the CA bundle: run `hermes doctor --fix`, or \"\n f\"`{sys.executable} -m pip install --force-reinstall certifi`\"\n )\n return\n\n # --fix: force-reinstall certifi into the running interpreter's env and\n # re-verify. importlib caches are invalidated so certifi.where() resolves\n # the fresh install without a process restart.\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n print(\" → Repairing: force-reinstalling certifi...\")\n try:\n result = subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"--force-reinstall\", \"certifi\"],\n capture_output=True,\n text=True,\n timeout=300,\n )\n except Exception as exc:\n check_fail(\"certifi repair could not run pip\", str(exc))\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n if result.returncode != 0:\n tail = (result.stderr or result.stdout or \"\")[-500:]\n check_fail(\"certifi reinstall failed\", tail)\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n\n # Drop any cached certifi module so where() re-resolves the new bundle.\n import importlib\n for mod_name in [m for m in sys.modules if m == \"certifi\" or m.startswith(\"certifi.\")]:\n sys.modules.pop(mod_name, None)\n importlib.invalidate_caches()\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle repaired (certifi reinstalled)\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle still broken after reinstall\", str(e))\n if issues is not None:\n issues.append(\n \"certifi reinstall did not restore the CA bundle — check for a \"\n \"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing \"\n \"at a missing file, or recreate the venv.\"\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` because runtime repair remains\n # best-effort and unsupported installs may need manual action.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(run `hermes update`; fixed versions: 3.51.3+ / 3.50.7 / \"\n \"3.44.6 — see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates(should_fix=should_fix, issues=manual_issues)\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to\n # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 —\n # matches hermes_cli.env_loader._load_dotenv_with_fallback.\n try:\n content = env_path.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n content = env_path.read_text(encoding=\"latin-1\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text(encoding=\"utf-8\"))\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text(encoding=\"utf-8\")\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "efaba061fb58dc7b1f6ee42b119f7fdf93f499bc": {"t": 1785753304.6476424, "n": 117757, "h": "374c0843393a3a7a", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates(should_fix: bool = False, issues: \"list | None\" = None) -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n\n With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g.\n after a brew Python upgrade rebuilt the venv, #29866) is repaired by\n force-reinstalling certifi into THIS interpreter's environment and\n re-verifying.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n return\n except SSLConfigurationError as e:\n first_error = str(e)\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n if not should_fix:\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n if issues is not None:\n issues.append(\n \"Repair the CA bundle: run `hermes doctor --fix`, or \"\n f\"`{sys.executable} -m pip install --force-reinstall certifi`\"\n )\n return\n\n # --fix: force-reinstall certifi into the running interpreter's env and\n # re-verify. importlib caches are invalidated so certifi.where() resolves\n # the fresh install without a process restart.\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n print(\" → Repairing: force-reinstalling certifi...\")\n try:\n result = subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"--force-reinstall\", \"certifi\"],\n capture_output=True,\n text=True,\n timeout=300,\n )\n except Exception as exc:\n check_fail(\"certifi repair could not run pip\", str(exc))\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n if result.returncode != 0:\n tail = (result.stderr or result.stdout or \"\")[-500:]\n check_fail(\"certifi reinstall failed\", tail)\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n\n # Drop any cached certifi module so where() re-resolves the new bundle.\n import importlib\n for mod_name in [m for m in sys.modules if m == \"certifi\" or m.startswith(\"certifi.\")]:\n sys.modules.pop(mod_name, None)\n importlib.invalidate_caches()\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle repaired (certifi reinstalled)\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle still broken after reinstall\", str(e))\n if issues is not None:\n issues.append(\n \"certifi reinstall did not restore the CA bundle — check for a \"\n \"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing \"\n \"at a missing file, or recreate the venv.\"\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` because runtime repair remains\n # best-effort and unsupported installs may need manual action.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(run `hermes update`; fixed versions: 3.51.3+ / 3.50.7 / \"\n \"3.44.6 — see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates(should_fix=should_fix, issues=manual_issues)\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to\n # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 —\n # matches hermes_cli.env_loader._load_dotenv_with_fallback.\n try:\n content = env_path.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n content = env_path.read_text(encoding=\"latin-1\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text(encoding=\"utf-8\"))\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text(encoding=\"utf-8\")\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "60dbce7c34903bb83057baec94525efcb0a8cd87": {"t": 1785753306.695485, "n": 117725, "h": "ef3e705ad862be2e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates(should_fix: bool = False, issues: \"list | None\" = None) -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n\n With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g.\n after a brew Python upgrade rebuilt the venv, #29866) is repaired by\n force-reinstalling certifi into THIS interpreter's environment and\n re-verifying.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n return\n except SSLConfigurationError as e:\n first_error = str(e)\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n if not should_fix:\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n if issues is not None:\n issues.append(\n \"Repair the CA bundle: run `hermes doctor --fix`, or \"\n f\"`{sys.executable} -m pip install --force-reinstall certifi`\"\n )\n return\n\n # --fix: force-reinstall certifi into the running interpreter's env and\n # re-verify. importlib caches are invalidated so certifi.where() resolves\n # the fresh install without a process restart.\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n print(\" → Repairing: force-reinstalling certifi...\")\n try:\n result = subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"--force-reinstall\", \"certifi\"],\n capture_output=True,\n text=True,\n timeout=300,\n )\n except Exception as exc:\n check_fail(\"certifi repair could not run pip\", str(exc))\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n if result.returncode != 0:\n tail = (result.stderr or result.stdout or \"\")[-500:]\n check_fail(\"certifi reinstall failed\", tail)\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n\n # Drop any cached certifi module so where() re-resolves the new bundle.\n import importlib\n for mod_name in [m for m in sys.modules if m == \"certifi\" or m.startswith(\"certifi.\")]:\n sys.modules.pop(mod_name, None)\n importlib.invalidate_caches()\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle repaired (certifi reinstalled)\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle still broken after reinstall\", str(e))\n if issues is not None:\n issues.append(\n \"certifi reinstall did not restore the CA bundle — check for a \"\n \"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing \"\n \"at a missing file, or recreate the venv.\"\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` because runtime repair remains\n # best-effort and unsupported installs may need manual action.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(run `hermes update`; fixed versions: 3.51.3+ / 3.50.7 / \"\n \"3.44.6 — see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates(should_fix=should_fix, issues=manual_issues)\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Prefer UTF-8 (.env is written as UTF-8 elsewhere). Fall back to\n # latin-1 for Windows Notepad/cp1252 files that are not valid UTF-8 —\n # matches hermes_cli.env_loader._load_dotenv_with_fallback.\n try:\n content = env_path.read_text(encoding=\"utf-8\")\n except UnicodeDecodeError:\n content = env_path.read_text(encoding=\"latin-1\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "17bf3c828323115e5c07339414b79edbe4a9a6d7": {"t": 1785753308.8478384, "n": 117705, "h": "089837617d279843", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates(should_fix: bool = False, issues: \"list | None\" = None) -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n\n With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g.\n after a brew Python upgrade rebuilt the venv, #29866) is repaired by\n force-reinstalling certifi into THIS interpreter's environment and\n re-verifying.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n return\n except SSLConfigurationError as e:\n first_error = str(e)\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n if not should_fix:\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n if issues is not None:\n issues.append(\n \"Repair the CA bundle: run `hermes doctor --fix`, or \"\n f\"`{sys.executable} -m pip install --force-reinstall certifi`\"\n )\n return\n\n # --fix: force-reinstall certifi into the running interpreter's env and\n # re-verify. importlib caches are invalidated so certifi.where() resolves\n # the fresh install without a process restart.\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n print(\" → Repairing: force-reinstalling certifi...\")\n try:\n result = subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"--force-reinstall\", \"certifi\"],\n capture_output=True,\n text=True,\n timeout=300,\n )\n except Exception as exc:\n check_fail(\"certifi repair could not run pip\", str(exc))\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n if result.returncode != 0:\n tail = (result.stderr or result.stdout or \"\")[-500:]\n check_fail(\"certifi reinstall failed\", tail)\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n\n # Drop any cached certifi module so where() re-resolves the new bundle.\n import importlib\n for mod_name in [m for m in sys.modules if m == \"certifi\" or m.startswith(\"certifi.\")]:\n sys.modules.pop(mod_name, None)\n importlib.invalidate_caches()\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle repaired (certifi reinstalled)\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle still broken after reinstall\", str(e))\n if issues is not None:\n issues.append(\n \"certifi reinstall did not restore the CA bundle — check for a \"\n \"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing \"\n \"at a missing file, or recreate the venv.\"\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` because runtime repair remains\n # best-effort and unsupported installs may need manual action.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(run `hermes update`; fixed versions: 3.51.3+ / 3.50.7 / \"\n \"3.44.6 — see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates(should_fix=should_fix, issues=manual_issues)\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "98470ae33b203b8a753b3205a839d9021c05513d": {"t": 1785753311.7799633, "n": 117706, "h": "dc3a999f6f169b6b", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates(should_fix: bool = False, issues: \"list | None\" = None) -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n\n With ``--fix``, a broken bundle (missing/corrupt ``cacert.pem`` — e.g.\n after a brew Python upgrade rebuilt the venv, #29866) is repaired by\n force-reinstalling certifi into THIS interpreter's environment and\n re-verifying.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n return\n except SSLConfigurationError as e:\n first_error = str(e)\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n return\n\n if not should_fix:\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n if issues is not None:\n issues.append(\n \"Repair the CA bundle: run `hermes doctor --fix`, or \"\n f\"`{sys.executable} -m pip install --force-reinstall certifi`\"\n )\n return\n\n # --fix: force-reinstall certifi into the running interpreter's env and\n # re-verify. importlib caches are invalidated so certifi.where() resolves\n # the fresh install without a process restart.\n check_fail(\"SSL CA certificate bundle is broken\", first_error)\n print(\" → Repairing: force-reinstalling certifi...\")\n try:\n result = subprocess.run(\n [sys.executable, \"-m\", \"pip\", \"install\", \"--force-reinstall\", \"certifi\"],\n capture_output=True,\n text=True,\n timeout=300,\n )\n except Exception as exc:\n check_fail(\"certifi repair could not run pip\", str(exc))\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n if result.returncode != 0:\n tail = (result.stderr or result.stdout or \"\")[-500:]\n check_fail(\"certifi reinstall failed\", tail)\n if issues is not None:\n issues.append(\n f\"Reinstall certifi manually: {sys.executable} -m pip install \"\n \"--force-reinstall certifi\"\n )\n return\n\n # Drop any cached certifi module so where() re-resolves the new bundle.\n import importlib\n for mod_name in [m for m in sys.modules if m == \"certifi\" or m.startswith(\"certifi.\")]:\n sys.modules.pop(mod_name, None)\n importlib.invalidate_caches()\n\n try:\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle repaired (certifi reinstalled)\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle still broken after reinstall\", str(e))\n if issues is not None:\n issues.append(\n \"certifi reinstall did not restore the CA bundle — check for a \"\n \"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing \"\n \"at a missing file, or recreate the venv.\"\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` — users often cannot change the\n # SQLite embedded in python-build-standalone via `hermes update`.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — \"\n \"see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates(should_fix=should_fix, issues=manual_issues)\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "c89481db5ec2400e936de6ac402c89e7a39cfe19": {"t": 1785753314.67745, "n": 114912, "h": "fe6f0c2e26d3c0be", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` — users often cannot change the\n # SQLite embedded in python-build-standalone via `hermes update`.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — \"\n \"see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True, encoding='utf-8', errors='replace',\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "c1f5f0f9115ef779bf08cd2de70326a5ce4877cf": {"t": 1785753316.4163096, "n": 114840, "h": "df8e9e9da3103f62", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` — users often cannot change the\n # SQLite embedded in python-build-standalone via `hermes update`.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — \"\n \"see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\", \"moa\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "8058e01834233bb56e575f46c2213bec64ffd83f": {"t": 1785753318.5024977, "n": 114833, "h": "b5641bf5387f3425", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` — users often cannot change the\n # SQLite embedded in python-build-standalone via `hermes update`.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — \"\n \"see https://sqlite.org/wal.html#walresetbug)\",\n )\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "953cbc030075d644ab1afa9bb044bad1990dfbc5": {"t": 1785753321.2217462, "n": 114949, "h": "6dccc9f9c8aea0b8", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n\n # Linked SQLite library (issue #69784): version + source id matter independently\n # of the Python minor — uv's python-build-standalone can keep a vulnerable\n # SQLite across Python upgrades.\n try:\n import sqlite3\n from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id\n\n _sqlite_ver = sqlite3.sqlite_version\n _sqlite_src = sqlite_source_id()\n _sqlite_src_short = (\n (_sqlite_src[:48] + \"…\") if len(_sqlite_src) > 48 else _sqlite_src\n )\n if is_sqlite_wal_reset_vulnerable():\n # Warn-only: Hermes already refuses to enable WAL on fresh DBs.\n # Do not append to ``issues`` — users often cannot change the\n # SQLite embedded in python-build-standalone via `hermes update`.\n check_warn(\n f\"SQLite {_sqlite_ver} (WAL-reset bug)\",\n \"(new shared DBs use DELETE; prefer 3.51.3+ / 3.50.7 / 3.44.6 — \"\n \"see https://sqlite.org/wal.html#walresetbug)\",\n )\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n else:\n check_ok(f\"SQLite {_sqlite_ver}\")\n if _sqlite_src_short:\n check_info(f\"SQLite source id: {_sqlite_src_short}\")\n except Exception as e:\n check_warn(f\"SQLite version probe failed: {e}\")\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "7de06f700ed933da19013c150ce52dfecf4974e7": {"t": 1785753325.6403112, "n": 113564, "h": "2f5e5f320a20a4d6", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n from hermes_cli.config import is_provider_enabled\n known_providers.update(\n str(name).strip().lower()\n for name, prov_cfg in user_providers.items()\n if str(name).strip() and is_provider_enabled(prov_cfg)\n )\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "336c3b13aa16f2686695d92ae98321a3ead65d42": {"t": 1785753328.2152662, "n": 113368, "h": "89755111ea88cc4e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\n# Deprecated / legacy config keys still read for back-compat. Doctor surfaces\n# them as non-failing warnings with the modern replacement — it does not\n# auto-migrate or delete (migrations live in config.py version steps).\n_DEPRECATED_CONFIG_KEYS: tuple[tuple[str, str, str], ...] = (\n # (section, key, replacement)\n (\"display\", \"tool_progress_overrides\", \"display.platforms\"),\n (\"delegation\", \"max_async_children\", \"delegation.max_concurrent_children\"),\n)\n\n# compression.summary_* → auxiliary.compression (model/provider/base_url)\n_DEPRECATED_COMPRESSION_SUMMARY_KEYS: tuple[str, ...] = (\n \"summary_model\",\n \"summary_provider\",\n \"summary_base_url\",\n)\n\n# Deprecated env vars (checked in the .env file, not process env, so config→env\n# bridges like terminal.cwd → TERMINAL_CWD do not false-positive).\n_DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (\n (\"HERMES_TOOL_PROGRESS\", \"display.tool_progress in config.yaml\"),\n (\"HERMES_TOOL_PROGRESS_MODE\", \"display.tool_progress in config.yaml\"),\n (\"TERMINAL_CWD\", \"terminal.cwd in config.yaml\"),\n (\"MESSAGING_CWD\", \"terminal.cwd in config.yaml\"),\n (\"QQ_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL\"),\n (\"QQ_HOME_CHANNEL_NAME\", \"QQBOT_HOME_CHANNEL_NAME\"),\n)\n\n\ndef collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_path, replacement)`` for deprecated keys present in *raw_config*.\n\n Only keys that appear in the on-disk YAML are reported (raw file load, not\n merged defaults). Empty containers still count — presence of the legacy\n key is the signal that the user should migrate.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(raw_config, dict):\n return findings\n\n for section, key, replacement in _DEPRECATED_CONFIG_KEYS:\n section_val = raw_config.get(section)\n if isinstance(section_val, dict) and key in section_val:\n findings.append((f\"{section}.{key}\", replacement))\n\n compression = raw_config.get(\"compression\")\n if isinstance(compression, dict):\n for key in _DEPRECATED_COMPRESSION_SUMMARY_KEYS:\n if key in compression:\n findings.append((f\"compression.{key}\", \"auxiliary.compression\"))\n\n return findings\n\n\ndef collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:\n \"\"\"Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.\n\n *env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not\n ``os.environ``, so bridged runtime vars do not trigger false positives.\n \"\"\"\n findings: list[tuple[str, str]] = []\n if not isinstance(env_map, dict):\n return findings\n for name, replacement in _DEPRECATED_ENV_VARS:\n val = env_map.get(name)\n if val is not None and str(val).strip() != \"\":\n findings.append((name, replacement))\n return findings\n\n\ndef report_deprecated_config_and_env(\n raw_config: dict | None = None,\n env_map: dict | None = None,\n) -> list[tuple[str, str]]:\n \"\"\"Emit non-failing doctor warnings for deprecated config keys and env vars.\n\n Returns the list of ``(legacy, replacement)`` findings that were reported\n (empty when nothing deprecated is present). Does not mutate config/env and\n does not append to the blocking ``issues`` list.\n \"\"\"\n findings = collect_deprecated_config_keys(raw_config)\n findings.extend(collect_deprecated_env_vars(env_map))\n if not findings:\n check_ok(\"No deprecated config keys or env vars\")\n return findings\n\n for legacy, replacement in findings:\n check_warn(\n f\"Deprecated: {legacy}\",\n f\"(use {replacement} instead)\",\n )\n check_info(f\"Replace {legacy} → {replacement} (warn-only; not auto-migrated here)\")\n return findings\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Surface deprecated/legacy config keys and env vars (warn-only).\n # Migrations may still live in config.py version steps; doctor does\n # not auto-delete here — only tells the user the modern replacement.\n try:\n import yaml as _yaml_depr\n from hermes_cli.config import load_env as _load_env_depr\n\n with open(config_path, encoding=\"utf-8\") as _f_depr:\n _raw_for_depr = _yaml_depr.safe_load(_f_depr) or {}\n # Prefer the on-disk .env so bridged process env (e.g. TERMINAL_CWD\n # from terminal.cwd) does not false-positive.\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env(_raw_for_depr, _env_for_depr)\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n if not config_path.exists():\n # No config.yaml — still surface deprecated env vars from .env.\n try:\n from hermes_cli.config import load_env as _load_env_depr\n\n try:\n _env_for_depr = _load_env_depr()\n except Exception:\n _env_for_depr = {}\n report_deprecated_config_and_env({}, _env_for_depr)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "fe002eb124d9f9e23769afdfc437f716255983b1": {"t": 1785753329.7522993, "n": 108260, "h": "4abf95b3d7fc4512", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"DEEPINFRA_API_KEY\",\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n # DeepInfra is an aggregator-style gateway: its catalog\n # is exclusively ``vendor/model`` slugs (Qwen/Qwen3.5-…,\n # meta-llama/Llama-3-…, anthropic/claude-opus-4-7, …).\n \"deepinfra\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "c97d9a4c07b27c8ce94c10e83b916eea43a1e257": {"t": 1785753332.2196047, "n": 107990, "h": "fa660c46616e2f40", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"FIREWORKS_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n # Fireworks' native model IDs are slash-form\n # (accounts/fireworks/models/... and .../routers/...), so a \"/\"\n # is expected, not an aggregator vendor prefix.\n \"fireworks\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "123c6f3a23a39a45c79e093e81cd7def1f779a71": {"t": 1785753334.2138362, "n": 107731, "h": "4f3cfd669f1e8b28", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from hermes_cli.config import atomic_config_write\n atomic_config_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "6b21a935af24b7b3c4ee2370598471aa8978a243": {"t": 1785753337.156569, "n": 107715, "h": "ae4805b94a9fd5b2", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _enabled_cli_toolsets_for_doctor() -> set[str] | None:\n \"\"\"Return toolsets enabled for the CLI, or None if config resolution fails.\"\"\"\n try:\n from hermes_cli.config import load_config\n from hermes_cli.tools_config import _get_platform_tools\n\n return {str(toolset) for toolset in _get_platform_tools(load_config() or {}, \"cli\")}\n except Exception:\n return None\n\n\ndef _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]:\n \"\"\"Filter unavailable API-key toolsets to those enabled for the CLI.\"\"\"\n api_key_unavailable = [\n item for item in unavailable\n if item.get(\"missing_vars\") or item.get(\"env_vars\")\n ]\n enabled_toolsets = _enabled_cli_toolsets_for_doctor()\n if enabled_toolsets is None:\n return api_key_unavailable\n return [\n item for item in api_key_unavailable\n if str(item.get(\"name\") or \"\") in enabled_toolsets\n ]\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count missing API-key requirements only for toolsets enabled in the\n # current CLI platform. Default-off or explicitly disabled toolsets may\n # still show warnings above, but should not pollute the final summary.\n api_disabled = _missing_api_key_toolsets_for_summary(unavailable)\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "d3d621f7c38bb801d9d734cb2898bd4f9b134709": {"t": 1785753338.7407115, "n": 106614, "h": "fa6cb4a8b78db249", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "2ecca1e7d3e7c387b4d350f99dc699e2f43f73c5": {"t": 1785753340.317874, "n": 106672, "h": "f1469c7faf9a8024", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\nfrom hermes_cli import _subprocess_compat\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = _subprocess_compat.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = _subprocess_compat.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "0b7128582fad94d9bb5e9b36de4f937162fab7f2": {"t": 1785753344.0649552, "n": 106614, "h": "fa6cb4a8b78db249", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n\n # FTS write-health probe (#50502): `SELECT COUNT(*)` above succeeds\n # even when the FTS index is corrupt and every message write fails\n # through the triggers. `_db_opens_cleanly` now drives a rolled-back\n # write so this otherwise-silent corruption class is surfaced (and\n # repaired in place with --fix).\n from hermes_state import _db_opens_cleanly, repair_state_db_schema\n\n _write_reason = _db_opens_cleanly(state_db_path)\n if _write_reason is not None:\n check_warn(\n f\"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)\",\n f\"({_write_reason})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n \"Repaired state.db FTS write health\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db FTS write-health repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db FTS write corruption and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db FTS write corruption — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to rebuild the FTS index\"\n )\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "3c75e115712f2af2cabdfbbe3a7033ea486a697d": {"t": 1785753346.0325818, "n": 104539, "h": "1e4282c01e382bd8", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\nfrom hermes_constants import agent_browser_runnable\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n _which_ab = shutil.which(\"agent-browser\")\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab and agent_browser_runnable(_which_ab):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _which_ab:\n # Found on PATH but won't run — almost always a dangling global\n # symlink left behind by agent-browser's npm postinstall after a\n # `hermes update` wiped node_modules (issue #48521).\n check_warn(\n \"agent-browser found but not runnable\",\n f\"(broken symlink at {_which_ab}? run: npm install)\",\n )\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "7130d60861a9243301514bff611a9381830d59d8": {"t": 1785753348.4584398, "n": 104013, "h": "935f37d5e2a106da", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "170ef24c8f3b9b776e6112c964a8641fd7d3f428": {"t": 1785753350.166788, "n": 104904, "h": "fe66f28ae683c3a3", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n # The WhatsApp bridge may live under a writable HERMES_HOME mirror\n # instead of the (possibly read-only) install tree in Docker — resolve\n # it through the shared helper so we audit the dir that actually holds\n # node_modules. See #49561.\n try:\n from gateway.platforms.whatsapp_common import resolve_whatsapp_bridge_dir\n _whatsapp_bridge_dir = resolve_whatsapp_bridge_dir()\n except Exception:\n _whatsapp_bridge_dir = PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\"\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (_whatsapp_bridge_dir, \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "b0e47a98f9ed69cf4e292d0d213aff97499a36b5": {"t": 1785753351.8556464, "n": 104389, "h": "31cad38c7223973e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n try:\n from hermes_cli import managed_scope\n _raw_cfg = managed_scope.apply_managed_overlay(_raw_cfg)\n except Exception:\n pass\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "ddd519ea70d94232e297b287bb69798a43982c63": {"t": 1785753354.0932498, "n": 104195, "h": "de697b514e1fdc1d", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef managed_scope_check() -> None:\n \"\"\"Report the active managed scope (resolved dir + pinned key counts).\n\n Silent when no managed scope is present. When the managed directory was\n resolved from the HERMES_MANAGED_DIR override (rather than the system\n default), that is surfaced too — a redirected scope is the documented\n foot-gun (see docs/design/managed-scope.md §7) and an operator should see it.\n \"\"\"\n try:\n from hermes_cli import managed_scope\n managed_dir = managed_scope.get_managed_dir()\n except Exception: # noqa: BLE001 — diagnostics must never crash\n return\n if managed_dir is None:\n return\n n_cfg = len(managed_scope.managed_config_keys())\n n_env = len(managed_scope.load_managed_env())\n check_ok(\n f\"Managed scope active: {n_cfg} config key(s), {n_env} env key(s) \"\n f\"pinned by {managed_dir}\"\n )\n if os.environ.get(\"HERMES_MANAGED_DIR\", \"\").strip():\n check_info(f\"managed dir set via HERMES_MANAGED_DIR={managed_dir}\")\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Managed scope (administrator-pinned config/env), when present.\n managed_scope_check()\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "9df1a1a8de8ea3a9826201936a6c762edcfcac33": {"t": 1785753356.8965094, "n": 103071, "h": "f3f768b9c5c2dbfe", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n \"nvidia\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "972a9885ee207e732bba1e0b7c1a65716df322c1": {"t": 1785753360.0832691, "n": 103045, "h": "5525672f2a55668c", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n\n _section(\"MCP Server Security\")\n try:\n from hermes_cli.config import load_config\n from hermes_cli.mcp_security import validate_mcp_server_entry\n\n servers = load_config().get(\"mcp_servers\") or {}\n suspicious = 0\n if isinstance(servers, dict):\n for name, entry in sorted(servers.items()):\n if not isinstance(entry, dict):\n continue\n issues_found = validate_mcp_server_entry(name, entry)\n if not issues_found:\n continue\n suspicious += 1\n check_warn(f\"MCP server '{name}' has suspicious stdio command\", \"; \".join(issues_found))\n manual_issues.append(\n f\"Review/remove mcp_servers.{name} in config.yaml; rotate any credentials that may have been exposed.\"\n )\n if suspicious == 0:\n check_ok(\"No suspicious MCP stdio commands\")\n except Exception as e:\n check_warn(f\"MCP security check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "a218a0f1569cea01e217aad47af49ac61c2a8607": {"t": 1785753362.240176, "n": 102009, "h": "095505e247019075", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef check_certificates() -> None:\n \"\"\"Verify the certifi CA bundle is loadable.\n\n Surfaces the SSLConfigurationError user-friendly path before they hit\n a wall of tracebacks on the first outbound HTTPS call.\n \"\"\"\n try:\n from agent.ssl_guard import verify_ca_bundle_with_fallback\n from agent.errors import SSLConfigurationError\n verify_ca_bundle_with_fallback()\n check_ok(\"SSL CA certificate bundle is valid\")\n except SSLConfigurationError as e:\n check_fail(\"SSL CA certificate bundle is broken\", str(e))\n except Exception as e:\n check_warn(\"SSL certificate check skipped\", str(e))\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n\n _section(\"SSL / CA Certificates\")\n check_certificates()\n\n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "905ed413d1e8ee8875c8cef797e3501ab1ee9b34": {"t": 1785753364.8094132, "n": 101303, "h": "b621287a9585a11a", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n \n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. The root-level `npm audit fix` can crash on the\n # same tree with \"isDescendantOf\", so do not hand the user a\n # manual fix command for these build-tool advisories.\n fix_cmd = None\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n if fix_cmd:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}\"\n )\n else:\n vuln_detail = (\n f\"{critical} critical, {high} high, {moderate} moderate — \"\n \"build-tool advisory; clears via lockfile bump\"\n )\n check_warn(\n f\"{label} deps\",\n f\"({vuln_detail})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. Manual npm remediation may error with a known\n # arborist crash (edgesOut / isDescendantOf) on this monorepo\n # tree — in that case it is an npm bug, not a Hermes one.\n check_info(\n \" ^ build-time tooling (not runtime); if manual npm remediation \"\n \"errors with an arborist crash it's a known npm bug — clears \"\n \"via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "a5e9b17ce3ad5b39a9896497fef0398b7a9a0d9d": {"t": 1785753366.863633, "n": 101053, "h": "7b37e1b276842dd6", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n \n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n # Detection (`npm audit --workspace `) is read-only and\n # safe, but `npm audit fix --workspace ` crashes on\n # current npm with \"Cannot read properties of null (reading\n # 'edgesOut')\" — an arborist bug with workspace-filtered\n # audit fix. Recommend the root-level `npm audit fix`, which\n # operates over every workspace and does not crash, instead\n # of handing the user a command that errors out.\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n check_warn(\n f\"{label} deps\",\n f\"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})\"\n )\n if audit_extra and audit_extra[0] == \"--workspace\":\n # The web/ui-tui workspace advisories are in build-time\n # tooling (esbuild/vite, etc.), not runtime code that ships\n # to users. `npm audit fix` here may also error with a known\n # npm arborist crash (edgesOut / isDescendantOf) on this\n # monorepo tree — in that case it is an npm bug, not a\n # Hermes one, and the advisories clear via a lockfile bump\n # rather than a manual fix.\n check_info(\n \" ^ build-time tooling (not runtime); if `npm audit fix` \"\n \"errors with an npm arborist crash it's a known npm bug — \"\n \"clears via a lockfile bump\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "218452b05019e13812a25d1f7a17f798a4b0edf8": {"t": 1785753368.576034, "n": 99661, "h": "ed448d10e56a9d23", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n \n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n from hermes_state import is_malformed_db_error, repair_state_db_schema\n\n if is_malformed_db_error(e):\n # sqlite_master itself is malformed (e.g. duplicate\n # messages_fts) — every statement fails before it runs, so\n # this is NOT a plain FTS-index rebuild. Repair sqlite_master\n # in place (backup first; sessions/messages preserved).\n check_warn(\n f\"{_DHH}/state.db schema is malformed (sessions hidden until repaired)\",\n f\"({e})\",\n )\n if should_fix:\n report = repair_state_db_schema(state_db_path)\n if report.get(\"repaired\"):\n try:\n conn = sqlite3.connect(str(state_db_path))\n count = conn.execute(\n \"SELECT COUNT(*) FROM sessions\"\n ).fetchone()[0]\n conn.close()\n except Exception:\n count = \"?\"\n backup_name = (\n Path(report[\"backup_path\"]).name\n if report.get(\"backup_path\") else \"n/a\"\n )\n check_ok(\n f\"Repaired state.db schema ({count} sessions recovered)\",\n f\"(strategy: {report.get('strategy')}; backup: {backup_name})\",\n )\n fixed_count += 1\n else:\n check_warn(\n \"state.db schema repair did not recover automatically\",\n f\"({report.get('error')}; backup: {report.get('backup_path')})\",\n )\n issues.append(\n \"state.db schema malformed and auto-repair failed — \"\n \"restore from the backup copy beside state.db\"\n )\n else:\n issues.append(\n \"state.db schema malformed — run 'hermes doctor --fix' \"\n \"(or 'hermes sessions repair') to recover hidden sessions\"\n )\n else:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n fix_scope = \" \".join(audit_extra)\n fix_cmd = f\"cd {npm_dir} && npm audit fix {fix_scope}\"\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n check_warn(\n f\"{label} deps\",\n f\"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "aa424e51acbfd057a3ae81dce8d26e2d6ce7108c": {"t": 1785753370.411807, "n": 97322, "h": "c119904338ac6401", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n \n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them.\n # Vendor/model slugs are valid on aggregator-style providers and on any custom\n # provider — bare \"custom\" or a named \"custom:\" that fronts an OpenAI-compatible\n # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix.\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n provider_accepts_vendor_slug = (\n provider_policy_id in providers_accepting_vendor_slugs\n or provider_policy_id == \"custom\"\n or provider_policy_id.startswith(\"custom:\")\n )\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and not provider_accepts_vendor_slug\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n fix_scope = \" \".join(audit_extra)\n fix_cmd = f\"cd {npm_dir} && npm audit fix {fix_scope}\"\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n check_warn(\n f\"{label} deps\",\n f\"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}, "732ababa1a6fdb2a35dc2c9f8de37db699861ae7": {"t": 1785753372.6761804, "n": 96906, "h": "29b2c88758c596fe", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\nfrom hermes_cli.env_loader import load_hermes_dotenv\nfrom hermes_constants import display_hermes_home\n\nPROJECT_ROOT = get_project_root()\nHERMES_HOME = get_hermes_home()\n_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder)\n\n# Load environment variables from ~/.hermes/.env so API key checks work\n_env_path = get_env_path()\nload_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / \".env\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_constants import OPENROUTER_MODELS_URL\nfrom utils import base_url_host_matches\n\n\n_PROVIDER_ENV_HINTS = (\n \"OPENROUTER_API_KEY\",\n \"OPENAI_API_KEY\",\n \"ANTHROPIC_API_KEY\",\n \"ANTHROPIC_TOKEN\",\n \"OPENAI_BASE_URL\",\n \"NOUS_API_KEY\",\n \"GLM_API_KEY\",\n \"ZAI_API_KEY\",\n \"Z_AI_API_KEY\",\n \"KIMI_API_KEY\",\n \"KIMI_CN_API_KEY\",\n \"GMI_API_KEY\",\n \"MINIMAX_API_KEY\",\n \"MINIMAX_CN_API_KEY\",\n \"KILOCODE_API_KEY\",\n \"DEEPSEEK_API_KEY\",\n \"DASHSCOPE_API_KEY\",\n \"HF_TOKEN\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_API_KEY\",\n \"TOKENHUB_API_KEY\",\n)\n\n\nfrom hermes_constants import is_termux as _is_termux\n\n\ndef _python_install_cmd() -> str:\n return \"python -m pip install\" if _is_termux() else \"uv pip install\"\n\n\ndef _system_package_install_cmd(pkg: str) -> str:\n if _is_termux():\n return f\"pkg install {pkg}\"\n if sys.platform == \"darwin\":\n return f\"brew install {pkg}\"\n return f\"sudo apt install {pkg}\"\n\n\ndef _safe_which(cmd: str) -> str | None:\n \"\"\"shutil.which wrapper resilient to platform monkeypatching in tests.\"\"\"\n try:\n return shutil.which(cmd)\n except Exception:\n return None\n\n\ndef _termux_browser_setup_steps(node_installed: bool) -> list[str]:\n steps: list[str] = []\n step = 1\n if not node_installed:\n steps.append(f\"{step}) pkg install nodejs\")\n step += 1\n steps.append(f\"{step}) npm install -g agent-browser\")\n steps.append(f\"{step + 1}) agent-browser install\")\n return steps\n\n\ndef _termux_install_all_fallback_notes() -> list[str]:\n return [\n \"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).\",\n \"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).\",\n \"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).\",\n \"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).\",\n ]\n\n\ndef _has_provider_env_config(content: str) -> bool:\n \"\"\"Return True when ~/.hermes/.env contains provider auth/base URL settings.\"\"\"\n return any(key in content for key in _PROVIDER_ENV_HINTS)\n\n\ndef _honcho_is_configured_for_doctor() -> bool:\n \"\"\"Return True when Honcho is configured, even if this process has no active session.\"\"\"\n try:\n from plugins.memory.honcho.client import HonchoClientConfig\n\n cfg = HonchoClientConfig.from_global_config()\n return bool(cfg.enabled and (cfg.api_key or cfg.base_url))\n except Exception:\n return False\n\n\ndef _is_kanban_worker_env_gate(item: dict) -> bool:\n \"\"\"Return True when Kanban is unavailable only because this is not a worker process.\"\"\"\n if item.get(\"name\") != \"kanban\":\n return False\n if os.environ.get(\"HERMES_KANBAN_TASK\"):\n return False\n\n tools = item.get(\"tools\") or []\n return bool(tools) and all(str(tool).startswith(\"kanban_\") for tool in tools)\n\n\ndef _doctor_tool_availability_detail(toolset: str) -> str:\n \"\"\"Optional explanatory suffix for toolsets whose doctor status needs context.\"\"\"\n if toolset == \"kanban\" and not os.environ.get(\"HERMES_KANBAN_TASK\"):\n return \"(runtime-gated; loaded only for dispatcher-spawned workers)\"\n return \"\"\n\n\ndef _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:\n \"\"\"Adjust runtime-gated tool availability for doctor diagnostics.\"\"\"\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n name = item.get(\"name\")\n if _is_kanban_worker_env_gate(item):\n if \"kanban\" not in updated_available:\n updated_available.append(\"kanban\")\n continue\n if name == \"honcho\" and _honcho_is_configured_for_doctor():\n if \"honcho\" not in updated_available:\n updated_available.append(\"honcho\")\n continue\n updated_unavailable.append(item)\n return updated_available, updated_unavailable\n\n\ndef _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:\n \"\"\"Return True when a direct API-key probe failure is non-blocking.\n\n Some provider families support both a direct API-key path and a separate\n OAuth runtime path. When the OAuth path is already healthy, doctor should\n still show a failed API-key connectivity row, but it should not promote\n that direct-key problem into the final blocking summary.\n \"\"\"\n normalized = (provider_label or \"\").strip().lower()\n if normalized in {\"google / gemini\", \"gemini\"}:\n try:\n from hermes_cli.auth import get_gemini_oauth_auth_status\n return bool((get_gemini_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"minimax\":\n try:\n from hermes_cli.auth import get_minimax_oauth_auth_status\n return bool((get_minimax_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n if normalized == \"xai\":\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n return bool((get_xai_oauth_auth_status() or {}).get(\"logged_in\"))\n except Exception:\n return False\n return False\n\n\ndef check_ok(text: str, detail: str = \"\"):\n print(f\" {color('✓', Colors.GREEN)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_warn(text: str, detail: str = \"\"):\n print(f\" {color('⚠', Colors.YELLOW)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_fail(text: str, detail: str = \"\"):\n print(f\" {color('✗', Colors.RED)} {text}\" + (f\" {color(detail, Colors.DIM)}\" if detail else \"\"))\n\ndef check_info(text: str):\n print(f\" {color('→', Colors.CYAN)} {text}\")\n\n\ndef _section(title: str) -> None:\n \"\"\"Print a doctor section banner: blank line + bold cyan ◆ title.\"\"\"\n print()\n print(color(f\"◆ {title}\", Colors.CYAN, Colors.BOLD))\n\n\ndef _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None:\n \"\"\"Emit a check_fail and append the corresponding fix instruction.\"\"\"\n check_fail(text, detail)\n issues.append(fix)\n\n\ndef _read_pyproject_version() -> str | None:\n \"\"\"Read the ``version = \"...\"`` from ``pyproject.toml`` at the project root.\n\n Returns None when running from an installed wheel (no pyproject.toml ships\n with the package) or when the file can't be parsed. Reads only the\n ``[project]`` version, ignoring any version strings that appear in other\n tables.\n \"\"\"\n pyproject = PROJECT_ROOT / \"pyproject.toml\"\n try:\n text = pyproject.read_text(encoding=\"utf-8\")\n except OSError:\n return None\n in_project = False\n for raw in text.splitlines():\n line = raw.strip()\n if line.startswith(\"[\") and line.endswith(\"]\"):\n in_project = line == \"[project]\"\n continue\n if in_project and line.startswith(\"version\") and \"=\" in line:\n value = line.split(\"=\", 1)[1]\n value = value.split(\"#\", 1)[0].strip().strip(\"\\\"'\")\n return value or None\n return None\n\n\ndef _check_version_consistency(issues: list[str]) -> None:\n \"\"\"Verify pyproject.toml version matches hermes_cli.__version__.\n\n A git conflict resolution (reset/merge) can revert one file without the\n other, leaving ``hermes --version`` reporting a stale version while\n ``pyproject.toml`` is current. Detect that drift so users can re-sync.\n Silent no-op for installed wheels where pyproject.toml isn't present.\n \"\"\"\n try:\n from hermes_cli import __version__ as init_version\n except Exception:\n return\n pyproject_version = _read_pyproject_version()\n if pyproject_version is None:\n # Installed wheel or unreadable pyproject — nothing to cross-check.\n return\n if pyproject_version == init_version:\n check_ok(\"Version files consistent\", f\"({init_version})\")\n else:\n _fail_and_issue(\n \"Version mismatch between source files\",\n f\"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})\",\n \"Re-sync version files (e.g. run 'hermes update', or set \"\n \"hermes_cli/__init__.py __version__ to match pyproject.toml)\",\n issues,\n )\n\n\ndef _check_s6_supervision(issues: list[str]) -> None:\n \"\"\"Inside a container under our s6 /init, surface what s6 sees.\n\n Runs as a counterpart to :func:`_check_gateway_service_linger` for\n the systemd-on-host case. No-op everywhere except in the s6\n container so host runs aren't cluttered with irrelevant output.\n\n Reports:\n - Whether the main-hermes and dashboard static services are up\n - How many per-profile gateway slots are registered (via\n ``S6ServiceManager.list_profile_gateways()``) and how many are\n currently supervised as ``up``\n \"\"\"\n try:\n from hermes_cli.service_manager import (\n S6ServiceManager,\n detect_service_manager,\n )\n except Exception:\n return\n\n if detect_service_manager() != \"s6\":\n return\n\n _section(\"s6 Supervision\")\n\n mgr = S6ServiceManager()\n\n # Static services. They live under /run/service/ via s6-rc symlinks,\n # so the same s6-svstat probe works.\n for static in (\"main-hermes\", \"dashboard\"):\n if mgr.is_running(static):\n check_ok(f\"{static}: up\")\n else:\n check_info(f\"{static}: down (expected if not enabled via env)\")\n\n profiles = mgr.list_profile_gateways()\n if not profiles:\n check_info(\"No per-profile gateways registered yet — create one with `hermes profile create `\")\n return\n\n up_count = sum(1 for p in profiles if mgr.is_running(f\"gateway-{p}\"))\n check_ok(\n f\"Per-profile gateways: {up_count}/{len(profiles)} supervised up\"\n + (f\" ({', '.join(sorted(profiles))})\" if len(profiles) <= 8 else \"\")\n )\n\n\ndef _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\n\n Skipped inside a container running under s6 — the linger concept\n (user-systemd surviving SSH logout) doesn't apply there, and the\n s6 supervision state is surfaced separately by\n ``_check_s6_supervision``.\n \"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\n from hermes_cli.service_manager import detect_service_manager\n except Exception as e:\n check_warn(\"Gateway service linger\", f\"(could not import gateway helpers: {e})\")\n return\n\n if not is_linux():\n return\n\n # Inside a container under our s6 /init, _check_s6_supervision\n # reports the live supervision state; the linger warning would be\n # confusing here (no systemd, no logout, no \"lingering\" concept).\n if detect_service_manager() == \"s6\":\n return\n\n unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n _section(\"Gateway Service\")\n linger_enabled, linger_detail = get_systemd_linger_status()\n if linger_enabled is True:\n check_ok(\"Systemd linger enabled\", \"(gateway service survives logout)\")\n elif linger_enabled is False:\n check_warn(\"Systemd linger disabled\", \"(gateway may stop after logout)\")\n check_info(\"Run: sudo loginctl enable-linger $USER\")\n issues.append(\"Enable linger for the gateway user service: sudo loginctl enable-linger $USER\")\n else:\n check_warn(\"Could not verify systemd linger\", f\"({linger_detail})\")\n\n\n_APIKEY_PROVIDERS_CACHE: list | None = None\n\n\ndef _build_apikey_providers_list() -> list:\n \"\"\"Build the API-key provider health-check list once and cache it.\n\n Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint)\n Base list augmented with any ProviderProfile with auth_type=\"api_key\" not\n already present — adding plugins/model-providers// is sufficient to get into doctor.\n \"\"\"\n _static = [\n (\"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),\n (\"Kimi / Moonshot\", (\"KIMI_API_KEY\",), \"https://api.moonshot.ai/v1/models\", \"KIMI_BASE_URL\", True),\n (\"StepFun Step Plan\", (\"STEPFUN_API_KEY\",), \"https://api.stepfun.ai/step_plan/v1/models\", \"STEPFUN_BASE_URL\", True),\n (\"Kimi / Moonshot (China)\", (\"KIMI_CN_API_KEY\",), \"https://api.moonshot.cn/v1/models\", None, True),\n (\"Arcee AI\", (\"ARCEEAI_API_KEY\",), \"https://api.arcee.ai/api/v1/models\", \"ARCEE_BASE_URL\", True),\n (\"GMI Cloud\", (\"GMI_API_KEY\",), \"https://api.gmi-serving.com/v1/models\", \"GMI_BASE_URL\", True),\n (\"DeepSeek\", (\"DEEPSEEK_API_KEY\",), \"https://api.deepseek.com/v1/models\", \"DEEPSEEK_BASE_URL\", True),\n (\"Hugging Face\", (\"HF_TOKEN\",), \"https://router.huggingface.co/v1/models\", \"HF_BASE_URL\", True),\n (\"NVIDIA NIM\", (\"NVIDIA_API_KEY\",), \"https://integrate.api.nvidia.com/v1/models\", \"NVIDIA_BASE_URL\", True),\n (\"Alibaba/DashScope\", (\"DASHSCOPE_API_KEY\",), \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models\", \"DASHSCOPE_BASE_URL\", True),\n # MiniMax global: /v1 endpoint supports /models.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n # MiniMax CN: /v1 endpoint does NOT support /models (returns 404).\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", False),\n (\"Kilo Code\", (\"KILOCODE_API_KEY\",), \"https://api.kilo.ai/api/gateway/models\", \"KILOCODE_BASE_URL\", True),\n (\"OpenCode Zen\", (\"OPENCODE_ZEN_API_KEY\",), \"https://opencode.ai/zen/v1/models\", \"OPENCODE_ZEN_BASE_URL\", True),\n # OpenCode Go has no shared /models endpoint; skip the health check.\n (\"OpenCode Go\", (\"OPENCODE_GO_API_KEY\",), None, \"OPENCODE_GO_BASE_URL\", False),\n ]\n _known_names = {t[0] for t in _static}\n # Also index by profile canonical name so profiles without display_name\n # don't create duplicate entries for providers already in the static list.\n _known_canonical: set[str] = set()\n _name_to_canonical = {\n \"Z.AI / GLM\": \"zai\", \"Kimi / Moonshot\": \"kimi-coding\",\n \"StepFun Step Plan\": \"stepfun\", \"Kimi / Moonshot (China)\": \"kimi-coding-cn\",\n \"Arcee AI\": \"arcee\", \"GMI Cloud\": \"gmi\", \"DeepSeek\": \"deepseek\",\n \"Hugging Face\": \"huggingface\", \"NVIDIA NIM\": \"nvidia\",\n \"Alibaba/DashScope\": \"alibaba\", \"MiniMax\": \"minimax\",\n \"MiniMax (China)\": \"minimax-cn\",\n \"Kilo Code\": \"kilocode\", \"OpenCode Zen\": \"opencode-zen\",\n \"OpenCode Go\": \"opencode-go\",\n }\n for _label, _canonical in _name_to_canonical.items():\n _known_canonical.add(_canonical)\n # Providers that already have a dedicated health check above the generic\n # API-key loop (with custom headers/auth). Skip their pluggable profiles\n # here so the generic Bearer-auth loop doesn't run a duplicate, broken\n # check (e.g. Anthropic native API requires x-api-key, not Bearer).\n _dedicated_canonical = {\"anthropic\", \"openrouter\", \"bedrock\"}\n _known_canonical.update(_dedicated_canonical)\n try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\n try:\n from hermes_cli.providers import normalize_provider as _normalize_provider\n except Exception: # pragma: no cover - normalization is best-effort\n def _normalize_provider(_name: str) -> str:\n return (_name or \"\").strip().lower()\n for _pp in list_providers():\n if not isinstance(_pp, _PP) or _pp.auth_type != \"api_key\" or not _pp.env_vars:\n continue\n _label = _pp.display_name or _pp.name\n if _label in _known_names or _pp.name in _known_canonical:\n continue\n _candidates = {_normalize_provider(_pp.name)}\n for _alias in (_pp.aliases or ()):\n _candidates.add(_normalize_provider(_alias))\n if _candidates & _dedicated_canonical:\n continue\n # Separate API-key vars from base-URL override vars — the health-check\n # loop sends the first found value as Authorization: Bearer, so a URL\n # string must never be picked.\n _key_vars = tuple(\n v for v in _pp.env_vars\n if not v.endswith(\"_BASE_URL\") and not v.endswith(\"_URL\")\n )\n _base_var = next(\n (v for v in _pp.env_vars if v.endswith(\"_BASE_URL\") or v.endswith(\"_URL\")),\n None,\n )\n if not _key_vars:\n continue\n _models_url = (\n (_pp.models_url or (_pp.base_url.rstrip(\"/\") + \"/models\"))\n if _pp.base_url else None\n )\n _hc = getattr(_pp, \"supports_health_check\", True)\n _static.append((_label, _key_vars, _models_url, _base_var, _hc))\n except Exception:\n pass\n return _static\n\n\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\n ack_target = getattr(args, 'ack', None)\n\n # Doctor runs from the interactive CLI, so CLI-gated tool availability\n # checks (like cronjob management) should see the same context as `hermes`.\n os.environ.setdefault(\"HERMES_INTERACTIVE\", \"1\")\n\n # Handle `hermes doctor --ack ` as a fast path. Persist the ack and\n # return without running the rest of the diagnostics — the user has\n # already seen the advisory and just wants to silence it.\n if ack_target:\n from hermes_cli.security_advisories import (\n ADVISORIES,\n ack_advisory,\n )\n valid_ids = {a.id for a in ADVISORIES}\n if ack_target not in valid_ids:\n print(color(\n f\"Unknown advisory ID: {ack_target!r}. Known IDs: \"\n f\"{', '.join(sorted(valid_ids)) or '(none)'}\",\n Colors.RED,\n ))\n sys.exit(2)\n if ack_advisory(ack_target):\n print(color(\n f\" ✓ Acknowledged advisory {ack_target}. \"\n f\"It will no longer trigger startup banners.\",\n Colors.GREEN,\n ))\n else:\n print(color(\n f\" ✗ Failed to persist ack for {ack_target}. \"\n f\"Check ~/.hermes/config.yaml is writable.\",\n Colors.RED,\n ))\n sys.exit(1)\n return\n\n issues = []\n manual_issues = [] # issues that can't be auto-fixed\n fixed_count = 0\n\n print()\n print(color(\"┌─────────────────────────────────────────────────────────┐\", Colors.CYAN))\n print(color(\"│ 🩺 Hermes Doctor │\", Colors.CYAN))\n print(color(\"└─────────────────────────────────────────────────────────┘\", Colors.CYAN))\n\n _section(\"Security Advisories\")\n try:\n from hermes_cli.security_advisories import (\n detect_compromised,\n filter_unacked,\n full_remediation_text,\n get_acked_ids,\n )\n all_hits = detect_compromised()\n fresh_hits = filter_unacked(all_hits)\n if fresh_hits:\n for hit in fresh_hits:\n check_fail(\n f\"{hit.advisory.title}\",\n f\"({hit.package}=={hit.installed_version})\",\n )\n # Print the full remediation block, indented under the\n # check_fail header so it reads as a single section.\n for line in full_remediation_text(hit):\n if line:\n print(f\" {color(line, Colors.YELLOW)}\")\n else:\n print()\n # Funnel into the action list so the summary block surfaces it\n # for users who scroll past the section.\n manual_issues.append(\n f\"Resolve security advisory {hit.advisory.id}: \"\n f\"uninstall {hit.package}=={hit.installed_version} and \"\n f\"rotate credentials, then run \"\n f\"`hermes doctor --ack {hit.advisory.id}`.\"\n )\n # Acked-but-still-installed: show as informational so the user\n # knows the package is still on disk after the ack.\n acked_ids = get_acked_ids()\n for h in all_hits:\n if h.advisory.id in acked_ids:\n check_warn(\n f\"{h.package}=={h.installed_version} still installed \"\n f\"(advisory {h.advisory.id} acknowledged)\",\n )\n else:\n check_ok(\"No active security advisories\")\n except Exception as e:\n # Never let a bug in the advisory check block the rest of doctor.\n check_warn(f\"Security advisory check failed: {e}\")\n \n _section(\"Python Environment\")\n py_version = sys.version_info\n if py_version >= (3, 11):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n elif py_version >= (3, 10):\n check_ok(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\")\n check_warn(\"Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)\")\n elif py_version >= (3, 8):\n check_warn(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ recommended)\")\n else:\n _fail_and_issue(\n f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\",\n \"(3.10+ required)\",\n \"Upgrade Python to 3.10+\",\n issues,\n )\n \n # Check if in virtual environment\n in_venv = sys.prefix != sys.base_prefix\n if in_venv:\n check_ok(\"Virtual environment active\")\n else:\n check_warn(\"Not in virtual environment\", \"(recommended)\")\n\n # Detect drift between pyproject.toml and hermes_cli/__init__.py versions\n # (a git conflict resolution can silently revert one but not the other).\n _check_version_consistency(issues)\n \n _section(\"Required Packages\")\n required_packages = [\n (\"openai\", \"OpenAI SDK\"),\n (\"rich\", \"Rich (terminal UI)\"),\n (\"dotenv\", \"python-dotenv\"),\n (\"yaml\", \"PyYAML\"),\n (\"httpx\", \"HTTPX\"),\n ]\n \n optional_packages = [\n (\"croniter\", \"Croniter (cron expressions)\"),\n (\"telegram\", \"python-telegram-bot\"),\n (\"discord\", \"discord.py\"),\n ]\n \n for module, name in required_packages:\n try:\n __import__(module)\n check_ok(name)\n except ImportError:\n _fail_and_issue(name, \"(missing)\", f\"Install {name}: {_python_install_cmd()} {module}\", issues)\n \n for module, name in optional_packages:\n try:\n __import__(module)\n check_ok(name, \"(optional)\")\n except ImportError:\n check_warn(name, \"(optional, not installed)\")\n \n _section(\"Configuration Files\")\n # Check ~/.hermes/.env (primary location for user config)\n env_path = HERMES_HOME / '.env'\n if env_path.exists():\n check_ok(f\"{_DHH}/.env file exists\")\n \n # Check for common issues. Pin encoding to UTF-8 because .env files are\n # written as UTF-8 everywhere in the codebase, while Path.read_text()\n # defaults to the system locale — which crashes on non-UTF-8 Windows\n # locales (e.g. GBK) as soon as the file contains any non-ASCII byte.\n content = env_path.read_text(encoding=\"utf-8\")\n if _has_provider_env_config(content):\n check_ok(\"API key or custom endpoint configured\")\n else:\n check_warn(f\"No API key found in {_DHH}/.env\")\n issues.append(\"Run 'hermes setup' to configure API keys\")\n else:\n # Also check project root as fallback\n fallback_env = PROJECT_ROOT / '.env'\n if fallback_env.exists():\n check_ok(\".env file exists (in project directory)\")\n else:\n check_fail(f\"{_DHH}/.env file missing\")\n if should_fix:\n env_path.parent.mkdir(parents=True, exist_ok=True)\n env_path.touch()\n # .env holds API keys — restrict to owner-only access from\n # creation. touch() obeys umask which is commonly 0o022,\n # leaving the file world-readable; tighten explicitly.\n try:\n os.chmod(str(env_path), 0o600)\n except OSError:\n pass\n check_ok(f\"Created empty {_DHH}/.env\")\n check_info(\"Run 'hermes setup' to configure API keys\")\n fixed_count += 1\n else:\n check_info(\"Run 'hermes setup' to create one\")\n issues.append(\"Run 'hermes setup' to create .env\")\n \n # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback)\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n check_ok(f\"{_DHH}/config.yaml exists\")\n\n # Validate model.provider and model.default values\n try:\n import yaml as _yaml\n cfg = _yaml.safe_load(config_path.read_text(encoding=\"utf-8\")) or {}\n model_section = cfg.get(\"model\") or {}\n provider_raw = (model_section.get(\"provider\") or \"\").strip()\n provider = provider_raw.lower()\n default_model = (model_section.get(\"default\") or model_section.get(\"model\") or \"\").strip()\n\n known_providers: set = set()\n try:\n from hermes_cli.auth import (\n PROVIDER_REGISTRY,\n resolve_provider as _resolve_auth_provider,\n )\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n _resolve_auth_provider = None\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import (\n normalize_provider as _normalize_catalog_provider,\n resolve_provider_full as _resolve_provider_full,\n )\n except Exception:\n _compatible_custom_providers = None\n _normalize_catalog_provider = None\n _resolve_provider_full = None\n\n custom_providers = []\n if _compatible_custom_providers is not None:\n try:\n custom_providers = _compatible_custom_providers(cfg)\n except Exception:\n custom_providers = []\n\n user_providers = cfg.get(\"providers\")\n if isinstance(user_providers, dict):\n known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip())\n for entry in custom_providers:\n if not isinstance(entry, dict):\n continue\n name = str(entry.get(\"name\") or \"\").strip()\n if name:\n known_providers.add(\"custom:\" + name.lower().replace(\" \", \"-\"))\n\n valid_provider_ids = set(known_providers)\n provider_ids_to_accept = {provider} if provider else set()\n if _normalize_catalog_provider is not None:\n for known_provider in known_providers:\n try:\n valid_provider_ids.add(_normalize_catalog_provider(known_provider))\n except Exception:\n continue\n\n runtime_provider = provider\n if (\n provider\n and _resolve_auth_provider is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n try:\n runtime_provider = _resolve_auth_provider(provider)\n provider_ids_to_accept.add(runtime_provider)\n except Exception:\n runtime_provider = provider\n\n catalog_provider = provider\n if (\n provider\n and _resolve_provider_full is not None\n and provider not in {\"auto\", \"custom\"}\n ):\n provider_def = _resolve_provider_full(provider, user_providers, custom_providers)\n catalog_provider = provider_def.id if provider_def is not None else None\n if catalog_provider is not None:\n provider_ids_to_accept.add(catalog_provider)\n\n if provider and provider != \"auto\":\n if catalog_provider is None or (\n known_providers\n and not (provider_ids_to_accept & valid_provider_ids)\n ):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n _fail_and_issue(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n (\n f\"model.provider '{provider_raw}' is unknown. \"\n f\"Valid providers: {known_list}. \"\n f\"Fix: run 'hermes config set model.provider '\"\n ),\n issues,\n )\n\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them\n provider_for_policy = runtime_provider or catalog_provider\n provider_policy_id = str(provider_for_policy or \"\").strip().lower()\n providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"kilocode\",\n \"opencode-zen\",\n \"huggingface\",\n \"lmstudio\",\n \"nous\",\n }\n if (\n default_model\n and \"/\" in default_model\n and provider_policy_id\n and provider_policy_id not in providers_accepting_vendor_slugs\n and not provider_policy_id.startswith(\"custom:\")\n ):\n check_warn(\n f\"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'\",\n \"(vendor-prefixed slugs belong to aggregators like openrouter)\",\n )\n issues.append(\n f\"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. \"\n \"Either set model.provider to 'openrouter', or drop the vendor prefix.\"\n )\n\n # Check credentials for the configured provider.\n # Limit to API-key providers in PROVIDER_REGISTRY — other provider\n # types (OAuth, SDK, anthropic/custom/auto) have their own env-var\n # checks elsewhere in doctor, and get_auth_status() returns a bare\n # {logged_in: False} for anything it doesn't explicitly dispatch,\n # which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\"):\n try:\n if runtime_provider == \"openrouter\":\n from hermes_cli.config import get_env_value\n\n configured = bool(\n str(get_env_value(\"OPENROUTER_API_KEY\") or \"\").strip()\n or str(get_env_value(\"OPENAI_API_KEY\") or \"\").strip()\n )\n else:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\n configured = True\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(runtime_provider) or {}\n configured = bool(\n status.get(\"configured\")\n or status.get(\"logged_in\")\n or status.get(\"api_key\")\n )\n if not configured:\n _fail_and_issue(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n (\n f\"No credentials found for provider '{runtime_provider}'. \"\n f\"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, \"\n f\"or switch providers with 'hermes config set model.provider '\"\n ),\n issues,\n )\n except Exception:\n pass\n\n except Exception as e:\n check_warn(\"Could not validate model/provider config\", f\"({e})\")\n else:\n fallback_config = PROJECT_ROOT / 'cli-config.yaml'\n if fallback_config.exists():\n check_ok(\"cli-config.yaml exists (in project directory)\")\n else:\n if should_fix:\n config_path.parent.mkdir(parents=True, exist_ok=True)\n example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if example_config.exists():\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n else:\n from hermes_cli.config import DEFAULT_CONFIG, save_config\n save_config(DEFAULT_CONFIG)\n check_ok(f\"Created {_DHH}/config.yaml from defaults\")\n fixed_count += 1\n else:\n check_warn(\"config.yaml not found\", \"(using defaults)\")\n\n # Check config version and stale keys\n config_path = HERMES_HOME / 'config.yaml'\n if config_path.exists():\n try:\n from hermes_cli.config import check_config_version, migrate_config\n current_ver, latest_ver = check_config_version()\n if current_ver < latest_ver:\n check_warn(\n f\"Config version outdated (v{current_ver} → v{latest_ver})\",\n \"(new settings available)\"\n )\n if should_fix:\n try:\n migrate_config(interactive=False, quiet=False)\n check_ok(\"Config migrated to latest version\")\n fixed_count += 1\n except Exception as mig_err:\n check_warn(f\"Auto-migration failed: {mig_err}\")\n issues.append(\"Run 'hermes setup' to migrate config\")\n else:\n issues.append(\"Run 'hermes doctor --fix' or 'hermes setup' to migrate config\")\n else:\n check_ok(f\"Config version up to date (v{current_ver})\")\n except Exception:\n pass\n\n # Detect stale root-level model keys (known bug source — PR #4329)\n try:\n import yaml\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n stale_root_keys = [k for k in (\"provider\", \"base_url\") if k in raw_config and isinstance(raw_config[k], str)]\n if stale_root_keys:\n check_warn(\n f\"Stale root-level config keys: {', '.join(stale_root_keys)}\",\n \"(should be under 'model:' section)\"\n )\n if should_fix:\n # Coerce scalar/None ``model:`` into a dict before mutation —\n # ``setdefault(\"model\", {})`` would return an existing scalar\n # and then ``model_section[k] = ...`` would raise TypeError.\n raw_model = raw_config.get(\"model\")\n if isinstance(raw_model, dict):\n model_section = raw_model\n elif isinstance(raw_model, str) and raw_model.strip():\n model_section = {\"default\": raw_model.strip()}\n raw_config[\"model\"] = model_section\n else:\n model_section = {}\n raw_config[\"model\"] = model_section\n for k in stale_root_keys:\n if not model_section.get(k):\n model_section[k] = raw_config.pop(k)\n else:\n raw_config.pop(k)\n from utils import atomic_yaml_write\n atomic_yaml_write(config_path, raw_config)\n check_ok(\"Migrated stale root-level keys into model section\")\n fixed_count += 1\n else:\n issues.append(\"Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'\")\n except Exception:\n pass\n\n # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing\n # agent.max_turns in config.yaml (issue #17534). The setup wizard\n # used to dual-write the iteration budget to both stores; users who\n # later edit only config.yaml are left with a .env ghost. The gateway\n # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns\n # at startup, but if that bridge bails (any earlier config-parse\n # error), the stale .env value silently wins and the agent runs at the\n # wrong budget — e.g. config says 400 but the activity line reads N/90.\n # Read the .env FILE directly (load_env), not get_env_value/os.environ,\n # which the startup bridge may already have overridden.\n try:\n import yaml\n from hermes_cli.config import load_env, remove_env_value\n with open(config_path, encoding=\"utf-8\") as f:\n raw_config = yaml.safe_load(f) or {}\n agent_cfg = raw_config.get(\"agent\")\n cfg_max_turns = (\n agent_cfg.get(\"max_turns\")\n if isinstance(agent_cfg, dict)\n else None\n )\n # Legacy root-level key counts too.\n if cfg_max_turns is None:\n cfg_max_turns = raw_config.get(\"max_turns\")\n env_ghost = load_env().get(\"HERMES_MAX_ITERATIONS\")\n drift = (\n cfg_max_turns is not None\n and env_ghost is not None\n and str(cfg_max_turns).strip() != str(env_ghost).strip()\n )\n if drift:\n check_warn(\n f\"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows \"\n f\"agent.max_turns={cfg_max_turns} in config.yaml\",\n \"(stale ghost from an earlier `hermes setup` run)\",\n )\n if should_fix:\n if remove_env_value(\"HERMES_MAX_ITERATIONS\"):\n check_ok(\n \"Removed stale HERMES_MAX_ITERATIONS from .env \"\n f\"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)\"\n )\n fixed_count += 1\n else:\n check_warn(\"Could not remove HERMES_MAX_ITERATIONS from .env\")\n manual_issues.append(\n \"Manually delete the HERMES_MAX_ITERATIONS line from \"\n f\"{_DHH}/.env — config.yaml agent.max_turns is authoritative.\"\n )\n else:\n issues.append(\n \"Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — \"\n \"run 'hermes doctor --fix'\"\n )\n except Exception:\n pass\n\n # Validate config structure (catches malformed custom_providers, etc.)\n try:\n from hermes_cli.config import validate_config_structure\n config_issues = validate_config_structure()\n if config_issues:\n _section(\"Config Structure\")\n for ci in config_issues:\n if ci.severity == \"error\":\n check_fail(ci.message)\n else:\n check_warn(ci.message)\n # Show the hint indented\n for hint_line in ci.hint.splitlines():\n check_info(hint_line)\n issues.append(ci.message)\n except Exception:\n pass\n\n _section(\"xAI Model Retirement (May 15, 2026)\")\n\n try:\n from hermes_cli.config import load_config\n from hermes_cli.xai_retirement import (\n MIGRATION_GUIDE_URL,\n find_retired_xai_refs,\n format_issue,\n )\n\n _xai_cfg = load_config()\n retired_refs = find_retired_xai_refs(_xai_cfg)\n if not retired_refs:\n check_ok(\"No retired xAI models in config\")\n else:\n for ref in retired_refs:\n check_warn(format_issue(ref))\n check_info(f\"Migration guide: {MIGRATION_GUIDE_URL}\")\n manual_issues.append(\n f\"Update {len(retired_refs)} retired xAI model reference(s) \"\n f\"in config.yaml — see {MIGRATION_GUIDE_URL}\"\n )\n except Exception as _xai_check_err:\n check_warn(\"xAI retirement check skipped\", f\"({_xai_check_err})\")\n\n _section(\"Auth Providers\")\n\n try:\n from hermes_cli.auth import (\n get_nous_auth_status,\n get_codex_auth_status,\n get_gemini_oauth_auth_status,\n get_minimax_oauth_auth_status,\n )\n\n nous_status = get_nous_auth_status()\n if nous_status.get(\"logged_in\"):\n check_ok(\"Nous Portal auth\", \"(logged in)\")\n else:\n check_warn(\"Nous Portal auth\", \"(not logged in)\")\n\n codex_status = get_codex_auth_status()\n if codex_status.get(\"logged_in\"):\n check_ok(\"OpenAI Codex auth\", \"(logged in)\")\n else:\n check_warn(\"OpenAI Codex auth\", \"(not logged in)\")\n if codex_status.get(\"error\"):\n check_info(codex_status[\"error\"])\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed to import existing tokens from ~/.codex/auth.json.\n # Attach the hint to the Codex auth row so it doesn't read as\n # remediation for whichever provider happens to print next (#27975).\n if not _safe_which(\"codex\"):\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens \"\n \"from an existing Codex CLI login)\"\n )\n\n gemini_status = get_gemini_oauth_auth_status()\n if gemini_status.get(\"logged_in\"):\n email = gemini_status.get(\"email\") or \"\"\n project = gemini_status.get(\"project_id\") or \"\"\n pieces = []\n if email:\n pieces.append(email)\n if project:\n pieces.append(f\"project={project}\")\n suffix = f\" ({', '.join(pieces)})\" if pieces else \"\"\n check_ok(\"Google Gemini OAuth\", f\"(logged in{suffix})\")\n else:\n check_warn(\"Google Gemini OAuth\", \"(not logged in)\")\n\n minimax_status = get_minimax_oauth_auth_status()\n if minimax_status.get(\"logged_in\"):\n region = minimax_status.get(\"region\", \"global\")\n check_ok(\"MiniMax OAuth\", f\"(logged in, region={region})\")\n else:\n check_warn(\"MiniMax OAuth\", \"(not logged in)\")\n except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n # xAI OAuth — separate try/except so an import failure here cannot\n # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above.\n try:\n from hermes_cli.auth import get_xai_oauth_auth_status\n xai_oauth_status = get_xai_oauth_auth_status() or {}\n if xai_oauth_status.get(\"logged_in\"):\n check_ok(\"xAI OAuth\", \"(logged in)\")\n else:\n check_warn(\"xAI OAuth\", \"(not logged in)\")\n if xai_oauth_status.get(\"error\"):\n check_info(xai_oauth_status[\"error\"])\n except Exception:\n pass\n\n _section(\"Directory Structure\")\n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n elif should_fix:\n hermes_home.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH} directory\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH} not found\", \"(will be created on first use)\")\n \n # Check expected subdirectories\n expected_subdirs = [\"cron\", \"sessions\", \"logs\", \"skills\", \"memories\"]\n for subdir_name in expected_subdirs:\n subdir_path = hermes_home / subdir_name\n if subdir_path.exists():\n check_ok(f\"{_DHH}/{subdir_name}/ exists\")\n elif should_fix:\n subdir_path.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/{subdir_name}/\")\n fixed_count += 1\n else:\n check_warn(f\"{_DHH}/{subdir_name}/ not found\", \"(will be created on first use)\")\n \n # Check for SOUL.md persona file\n soul_path = hermes_home / \"SOUL.md\"\n if soul_path.exists():\n content = soul_path.read_text(encoding=\"utf-8\").strip()\n # Check if it's just the template comments (no real content)\n lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith((\"\", \"#\"))]\n if lines:\n check_ok(f\"{_DHH}/SOUL.md exists (persona configured)\")\n else:\n check_info(f\"{_DHH}/SOUL.md exists but is empty — edit it to customize personality\")\n else:\n check_warn(f\"{_DHH}/SOUL.md not found\", \"(create it to give Hermes a custom personality)\")\n if should_fix:\n soul_path.parent.mkdir(parents=True, exist_ok=True)\n soul_path.write_text(\n \"# Hermes Agent Persona\\n\\n\"\n \"\\n\\n\"\n \"You are Hermes, a helpful AI assistant.\\n\",\n encoding=\"utf-8\",\n )\n check_ok(f\"Created {_DHH}/SOUL.md with basic template\")\n fixed_count += 1\n \n # Check memory directory\n memories_dir = hermes_home / \"memories\"\n if memories_dir.exists():\n check_ok(f\"{_DHH}/memories/ directory exists\")\n memory_file = memories_dir / \"MEMORY.md\"\n user_file = memories_dir / \"USER.md\"\n if memory_file.exists():\n size = len(memory_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"MEMORY.md exists ({size} chars)\")\n else:\n check_info(\"MEMORY.md not created yet (will be created when the agent first writes a memory)\")\n if user_file.exists():\n size = len(user_file.read_text(encoding=\"utf-8\").strip())\n check_ok(f\"USER.md exists ({size} chars)\")\n else:\n check_info(\"USER.md not created yet (will be created when the agent first writes a memory)\")\n else:\n check_warn(f\"{_DHH}/memories/ not found\", \"(will be created on first use)\")\n if should_fix:\n memories_dir.mkdir(parents=True, exist_ok=True)\n check_ok(f\"Created {_DHH}/memories/\")\n fixed_count += 1\n \n # Check SQLite session store\n state_db_path = hermes_home / \"state.db\"\n if state_db_path.exists():\n try:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n cursor = conn.execute(\"SELECT COUNT(*) FROM sessions\")\n count = cursor.fetchone()[0]\n conn.close()\n check_ok(f\"{_DHH}/state.db exists ({count} sessions)\")\n except Exception as e:\n check_warn(f\"{_DHH}/state.db exists but has issues: {e}\")\n else:\n check_info(f\"{_DHH}/state.db not created yet (will be created on first session)\")\n\n # Check WAL file size (unbounded growth indicates missed checkpoints)\n wal_path = hermes_home / \"state.db-wal\"\n if wal_path.exists():\n try:\n wal_size = wal_path.stat().st_size\n if wal_size > 50 * 1024 * 1024: # 50 MB\n check_warn(\n f\"WAL file is large ({wal_size // (1024*1024)} MB)\",\n \"(may indicate missed checkpoints)\"\n )\n if should_fix:\n import sqlite3\n conn = sqlite3.connect(str(state_db_path))\n conn.execute(\"PRAGMA wal_checkpoint(PASSIVE)\")\n conn.close()\n new_size = wal_path.stat().st_size if wal_path.exists() else 0\n check_ok(f\"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)\")\n fixed_count += 1\n else:\n issues.append(\"Large WAL file — run 'hermes doctor --fix' to checkpoint\")\n elif wal_size > 10 * 1024 * 1024: # 10 MB\n check_info(f\"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)\")\n except Exception:\n pass\n\n _check_gateway_service_linger(issues)\n _check_s6_supervision(issues)\n\n if sys.platform != \"win32\":\n _section(\"Command Installation\")\n # Determine the venv entry point location\n _venv_bin = None\n for _venv_name in (\"venv\", \".venv\"):\n _candidate = PROJECT_ROOT / _venv_name / \"bin\" / \"hermes\"\n if _candidate.exists():\n _venv_bin = _candidate\n break\n\n # Determine the expected command link directory (mirrors install.sh logic)\n _prefix = os.environ.get(\"PREFIX\", \"\")\n _is_termux_env = bool(os.environ.get(\"TERMUX_VERSION\")) or \"com.termux/files/usr\" in _prefix\n if _is_termux_env and _prefix:\n _cmd_link_dir = Path(_prefix) / \"bin\"\n _cmd_link_display = \"$PREFIX/bin\"\n else:\n _cmd_link_dir = Path.home() / \".local\" / \"bin\"\n _cmd_link_display = \"~/.local/bin\"\n _cmd_link = _cmd_link_dir / \"hermes\"\n\n if _venv_bin is None:\n check_warn(\n \"Venv entry point not found\",\n \"(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')\"\n )\n manual_issues.append(\n f\"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'\"\n )\n else:\n check_ok(f\"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})\")\n\n # Check the symlink at the command link location\n if _cmd_link.is_symlink():\n _target = _cmd_link.resolve()\n _expected = _venv_bin.resolve()\n if _target == _expected:\n check_ok(f\"{_cmd_link_display}/hermes → correct target\")\n else:\n check_warn(\n f\"{_cmd_link_display}/hermes points to wrong target\",\n f\"(→ {_target}, expected → {_expected})\"\n )\n if should_fix:\n _cmd_link.unlink()\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n else:\n issues.append(f\"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'\")\n elif _cmd_link.exists():\n # It's a regular file, not a symlink — possibly a wrapper script\n check_ok(f\"{_cmd_link_display}/hermes exists (non-symlink)\")\n else:\n check_fail(\n f\"{_cmd_link_display}/hermes not found\",\n \"(hermes command may not work outside the venv)\"\n )\n if should_fix:\n _cmd_link_dir.mkdir(parents=True, exist_ok=True)\n _cmd_link.symlink_to(_venv_bin)\n check_ok(f\"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}\")\n fixed_count += 1\n\n # Check if the link dir is on PATH\n _path_dirs = os.environ.get(\"PATH\", \"\").split(os.pathsep)\n if str(_cmd_link_dir) not in _path_dirs:\n check_warn(\n f\"{_cmd_link_display} is not on your PATH\",\n \"(add it to your shell config: export PATH=\\\"$HOME/.local/bin:$PATH\\\")\"\n )\n manual_issues.append(f\"Add {_cmd_link_display} to your PATH\")\n else:\n issues.append(f\"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'\")\n\n _section(\"External Tools\")\n # Git\n if _safe_which(\"git\"):\n check_ok(\"git\")\n else:\n check_warn(\"git not found\", \"(optional)\")\n \n # ripgrep (optional, for faster file search)\n if _safe_which(\"rg\"):\n check_ok(\"ripgrep (rg)\", \"(faster file search)\")\n else:\n check_warn(\"ripgrep (rg) not found\", \"(file search uses grep fallback)\")\n check_info(f\"Install for faster search: {_system_package_install_cmd('ripgrep')}\")\n \n # Docker (optional)\n terminal_env = os.getenv(\"TERMINAL_ENV\", \"local\")\n try:\n from hermes_constants import is_container as _is_container\n running_in_container = _is_container()\n except Exception:\n running_in_container = False\n\n if running_in_container:\n # Inside our container the Docker terminal backend is not\n # configured by default (Docker-in-Docker isn't set up); the\n # local backend is the intended one. Skip the noisy \"docker\n # not found\" warning. If the user has explicitly chosen\n # TERMINAL_ENV=docker inside the container they likely mounted\n # /var/run/docker.sock, so fall through to the normal check.\n if terminal_env != \"docker\":\n check_info(\n \"Running inside a container — using local terminal backend \"\n \"(docker-in-docker is not configured by default)\"\n )\n # Skip to next section; Docker isn't relevant here.\n terminal_env = \"local\"\n if terminal_env == \"docker\":\n if _safe_which(\"docker\"):\n # Check if docker daemon is running\n try:\n result = subprocess.run([\"docker\", \"info\"], capture_output=True, timeout=10)\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(\"docker\", \"(daemon running)\")\n else:\n _fail_and_issue(\"docker daemon not running\", \"\", \"Start Docker daemon\", issues)\n else:\n _fail_and_issue(\n \"docker not found\",\n \"(required for TERMINAL_ENV=docker)\",\n \"Install Docker or change TERMINAL_ENV\",\n issues,\n )\n elif _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n elif _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\n elif running_in_container:\n pass # already explained above\n else:\n check_warn(\"docker not found\", \"(optional)\")\n \n # SSH (if using ssh backend)\n if terminal_env == \"ssh\":\n ssh_host = os.getenv(\"TERMINAL_SSH_HOST\")\n if ssh_host:\n ssh_user = os.getenv(\"TERMINAL_SSH_USER\")\n ssh_port = os.getenv(\"TERMINAL_SSH_PORT\")\n ssh_key = os.getenv(\"TERMINAL_SSH_KEY\")\n target = f\"{ssh_user}@{ssh_host}\" if ssh_user else ssh_host\n cmd = [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\"]\n if ssh_port:\n cmd += [\"-p\", ssh_port]\n if ssh_key:\n cmd += [\"-i\", os.path.expanduser(ssh_key)]\n cmd += [target, \"echo ok\"]\n # Try to connect\n try:\n result = subprocess.run(\n cmd,\n capture_output=True,\n text=True,\n timeout=15\n )\n except subprocess.TimeoutExpired:\n result = None\n if result is not None and result.returncode == 0:\n check_ok(f\"SSH connection to {ssh_host}\")\n else:\n _fail_and_issue(f\"SSH connection to {ssh_host}\", \"\", f\"Check SSH configuration for {ssh_host}\", issues)\n else:\n _fail_and_issue(\n \"TERMINAL_SSH_HOST not set\",\n \"(required for TERMINAL_ENV=ssh)\",\n \"Set TERMINAL_SSH_HOST in .env\",\n issues,\n )\n \n # Daytona (if using daytona backend)\n if terminal_env == \"daytona\":\n daytona_key = os.getenv(\"DAYTONA_API_KEY\")\n if daytona_key:\n check_ok(\"Daytona API key\", \"(configured)\")\n else:\n _fail_and_issue(\n \"DAYTONA_API_KEY not set\",\n \"(required for TERMINAL_ENV=daytona)\",\n \"Set DAYTONA_API_KEY environment variable\",\n issues,\n )\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n _fail_and_issue(\n \"daytona SDK not installed\",\n \"(pip install daytona)\",\n \"Install daytona SDK: pip install daytona\",\n issues,\n )\n\n # Node.js + agent-browser (for browser automation tools)\n if _safe_which(\"node\"):\n check_ok(\"Node.js\")\n # Check if agent-browser is installed\n agent_browser_path = PROJECT_ROOT / \"node_modules\" / \"agent-browser\"\n agent_browser_ok = False\n if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n agent_browser_ok = True\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n agent_browser_ok = True\n elif _is_termux():\n check_info(\"agent-browser is not installed (expected in the tested Termux path)\")\n check_info(\"Install it manually later with: npm install -g agent-browser && agent-browser install\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=True):\n check_info(step)\n else:\n check_warn(\"agent-browser not installed\", \"(run: npm install)\")\n\n # Chromium presence — the browser tools silently fail to register when\n # agent-browser is found but no Playwright-managed Chromium is on disk\n # (tools/browser_tool.py::check_browser_requirements filters them out\n # before the agent ever sees them). Reuse the exact predicate it uses\n # so the two checks cannot diverge. Skip on Termux (not a tested\n # path).\n if agent_browser_ok and not _is_termux():\n try:\n # Lazy import: browser_tool is a ~150KB module we don't want\n # to eagerly load in every `hermes doctor` invocation.\n from tools.browser_tool import (\n _chromium_installed,\n _is_camofox_mode,\n _get_cloud_provider,\n _get_cdp_override,\n _using_lightpanda_engine,\n )\n except Exception:\n # If browser_tool can't even import, that's a separate bug\n # surfaced elsewhere; don't crash doctor.\n pass\n else:\n # Only warn about Chromium if the installed engine actually\n # requires it: Camofox, CDP override, a cloud provider, or\n # Lightpanda all bypass the local Chromium requirement.\n skip_chromium_check = (\n _is_camofox_mode()\n or bool(_get_cdp_override())\n or _get_cloud_provider() is not None\n or _using_lightpanda_engine()\n )\n if not skip_chromium_check:\n if _chromium_installed():\n check_ok(\"Playwright Chromium\", \"(browser engine)\")\n else:\n check_warn(\n \"Playwright Chromium not installed\",\n \"(browser_* tools will be hidden from the agent)\",\n )\n if sys.platform == \"win32\":\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install chromium\"\n )\n else:\n check_info(\n f\"Install with: cd {PROJECT_ROOT} && \"\n \"npx playwright install --with-deps chromium\"\n )\n elif _is_termux():\n check_info(\"Node.js not found (browser tools are optional in the tested Termux path)\")\n check_info(\"Install Node.js on Termux with: pkg install nodejs\")\n check_info(\"Termux browser setup:\")\n for step in _termux_browser_setup_steps(node_installed=False):\n check_info(step)\n else:\n check_warn(\"Node.js not found\", \"(optional, needed for browser tools)\")\n \n # npm audit for all Node.js packages\n _npm_bin = _safe_which(\"npm\")\n if _npm_bin:\n # Each entry: (cwd, label, extra_audit_args)\n # PROJECT_ROOT is audited with --workspaces=false so that the apps/*\n # glob (which pulls in Electron, node-pty, etc.) is never resolved\n # for a routine security check. The web and ui-tui workspaces are\n # audited separately via --workspace flags. See #38772.\n npm_audit_targets = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\", [\"--workspaces=false\"]),\n (PROJECT_ROOT, \"web workspace\", [\"--workspace\", \"web\"]),\n (PROJECT_ROOT, \"ui-tui workspace\", [\"--workspace\", \"ui-tui\"]),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\", []),\n ]\n for npm_dir, label, audit_extra in npm_audit_targets:\n # For workspace-scoped audits run from PROJECT_ROOT the\n # node_modules check must use the workspace root; standalone dirs\n # (whatsapp-bridge) check their own node_modules.\n check_dir = PROJECT_ROOT if audit_extra else npm_dir\n if not (check_dir / \"node_modules\").exists():\n continue\n try:\n # Use resolved absolute path so Windows can execute\n # npm.cmd (CreateProcessW can't run bare .cmd names).\n audit_result = subprocess.run(\n [_npm_bin, \"audit\", \"--json\", *audit_extra],\n cwd=str(npm_dir),\n capture_output=True, text=True, timeout=30,\n )\n import json as _json\n audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {}\n vuln_count = audit_data.get(\"metadata\", {}).get(\"vulnerabilities\", {})\n critical = vuln_count.get(\"critical\", 0)\n high = vuln_count.get(\"high\", 0)\n moderate = vuln_count.get(\"moderate\", 0)\n total = critical + high + moderate\n # Determine a scoped fix command for the remediation hint.\n if audit_extra and audit_extra[0] == \"--workspace\":\n fix_scope = \" \".join(audit_extra)\n fix_cmd = f\"cd {npm_dir} && npm audit fix {fix_scope}\"\n elif audit_extra == [\"--workspaces=false\"]:\n fix_cmd = f\"cd {npm_dir} && npm audit fix --workspaces=false\"\n else:\n fix_cmd = f\"cd {npm_dir} && npm audit fix\"\n if total == 0:\n check_ok(f\"{label} deps\", \"(no known vulnerabilities)\")\n elif critical > 0 or high > 0:\n check_warn(\n f\"{label} deps\",\n f\"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})\"\n )\n issues.append(\n f\"{label} has {total} npm \"\n f\"{'vulnerability' if total == 1 else 'vulnerabilities'}\"\n )\n else:\n check_ok(\n f\"{label} deps\",\n f\"({moderate} moderate \"\n f\"{'vulnerability' if moderate == 1 else 'vulnerabilities'})\",\n )\n except Exception:\n pass\n\n if _is_termux():\n check_info(\"Termux compatibility fallbacks:\")\n for note in _termux_install_all_fallback_notes():\n check_info(note)\n\n _section(\"API Connectivity\")\n # Refactor: every connectivity probe below is HTTP-bound and fully\n # independent. Running them in series spent ~5s wall on a typical\n # workstation (2s of that was boto3's IMDS lookup for AWS credentials,\n # which times out unless you're actually on EC2). Threading them with\n # a small executor pool collapses the section to roughly the slowest\n # single probe — about 2s — without changing the output format.\n #\n # Each ``_probe_*`` helper is a pure function: takes its inputs,\n # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying\n # the line(s) to print and any issue strings to append. No globals,\n # no shared mutable state, no printing inside the workers.\n import concurrent.futures as _futures\n from collections import namedtuple as _namedtuple\n\n _ConnectivityResult = _namedtuple(\n \"_ConnectivityResult\", [\"label\", \"lines\", \"issues\"]\n )\n _probes: list = [] # list of (label, callable) submitted in display order\n\n def _probe_openrouter() -> _ConnectivityResult:\n key = os.getenv(\"OPENROUTER_API_KEY\")\n if not key:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"⚠\", Colors.YELLOW), \"OpenRouter API\",\n color(\"(not configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n r = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {key}\"},\n timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✓\", Colors.GREEN), \"OpenRouter API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [\"Check OPENROUTER_API_KEY in .env\"],\n )\n if r.status_code == 402:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(out of credits — payment required)\", Colors.DIM))],\n [\"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' \"\n \"to switch providers, or fund your OpenRouter account \"\n \"at https://openrouter.ai/settings/credits\"],\n )\n if r.status_code == 429:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(\"(rate limited)\", Colors.DIM))],\n [\"OpenRouter rate limit hit — consider switching to \"\n \"a different provider or waiting\"],\n )\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"OpenRouter API\",\n [(color(\"✗\", Colors.RED), \"OpenRouter API\",\n color(f\"({e})\", Colors.DIM))],\n [\"Check network connectivity\"],\n )\n\n def _probe_anthropic() -> _ConnectivityResult:\n from hermes_cli.auth import get_anthropic_key\n key = get_anthropic_key()\n if not key:\n return _ConnectivityResult(\"Anthropic API\", [], [])\n try:\n import httpx\n from agent.anthropic_adapter import (\n _is_oauth_token,\n _COMMON_BETAS,\n _OAUTH_ONLY_BETAS,\n _CONTEXT_1M_BETA,\n )\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = key\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n # Reactive recovery: OAuth subscriptions without 1M context reject the\n # request with 400 \"long context beta is not yet available for this\n # subscription\". Retry once with that beta stripped so the doctor\n # check doesn't falsely report Anthropic as unreachable.\n if (\n is_oauth\n and r.status_code == 400\n and \"long context beta\" in r.text.lower()\n and \"not yet available\" in r.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA]\n + list(_OAUTH_ONLY_BETAS)\n )\n r = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✓\", Colors.GREEN), \"Anthropic API\", \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"✗\", Colors.RED), \"Anthropic API\",\n color(\"(invalid API key)\", Colors.DIM))],\n [],\n )\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(\"(couldn't verify)\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n \"Anthropic API\",\n [(color(\"⚠\", Colors.YELLOW), \"Anthropic API\",\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_apikey_provider(pname, env_vars, default_url, base_env,\n supports_health_check) -> _ConnectivityResult:\n key = \"\"\n for ev in env_vars:\n key = os.getenv(ev, \"\")\n if key:\n break\n if not key:\n return _ConnectivityResult(pname, [], [])\n label = pname.ljust(20)\n if not supports_health_check:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label,\n color(\"(key configured)\", Colors.DIM))],\n [],\n )\n try:\n import httpx\n base = os.getenv(base_env, \"\") if base_env else \"\"\n # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1\n # (OpenAI-compat surface, which exposes /models for health check).\n if not base and key.startswith(\"sk-kimi-\"):\n base = \"https://api.kimi.com/coding/v1\"\n # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding\n # with no /v1) don't support /models. Rewrite to OpenAI-compat\n # /v1 surface for health checks.\n if base and base.rstrip(\"/\").endswith(\"/anthropic\"):\n from agent.auxiliary_client import _to_openai_base_url\n base = _to_openai_base_url(base)\n if base_url_host_matches(base, \"api.kimi.com\") and base.rstrip(\"/\").endswith(\"/coding\"):\n base = base.rstrip(\"/\") + \"/v1\"\n url = (base.rstrip(\"/\") + \"/models\") if base else default_url\n headers = {\n \"Authorization\": f\"Bearer {key}\",\n \"User-Agent\": _HERMES_USER_AGENT,\n }\n if base_url_host_matches(base, \"api.kimi.com\"):\n headers[\"User-Agent\"] = \"claude-code/0.1.0\"\n # Google's Generative Language API (generativelanguage.googleapis.com)\n # rejects ``Authorization: Bearer `` with 401\n # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for\n # OAuth 2 access tokens, not plain API keys. Plain keys use\n # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid\n # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``.\n if url and base_url_host_matches(url, \"generativelanguage.googleapis.com\"):\n headers.pop(\"Authorization\", None)\n headers[\"x-goog-api-key\"] = key\n r = httpx.get(url, headers=headers, timeout=10)\n if (\n pname == \"Alibaba/DashScope\"\n and not base\n and r.status_code == 401\n ):\n r = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=headers, timeout=10,\n )\n if r.status_code == 200:\n return _ConnectivityResult(\n pname,\n [(color(\"✓\", Colors.GREEN), label, \"\")],\n [],\n )\n if r.status_code == 401:\n return _ConnectivityResult(\n pname,\n [(color(\"✗\", Colors.RED), label,\n color(\"(invalid API key)\", Colors.DIM))],\n [f\"Check {env_vars[0]} in .env\"],\n )\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(HTTP {r.status_code})\", Colors.DIM))],\n [],\n )\n except Exception as e:\n return _ConnectivityResult(\n pname,\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({e})\", Colors.DIM))],\n [],\n )\n\n def _probe_bedrock() -> _ConnectivityResult:\n try:\n from agent.bedrock_adapter import (\n has_aws_credentials,\n resolve_aws_auth_env_var,\n resolve_bedrock_region,\n )\n except ImportError:\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n if not has_aws_credentials():\n return _ConnectivityResult(\"AWS Bedrock\", [], [])\n auth_var = resolve_aws_auth_env_var()\n region = resolve_bedrock_region()\n label = \"AWS Bedrock\".ljust(20)\n try:\n import boto3\n from botocore.config import Config as _BotoConfig\n # Trim retries on the actual Bedrock API call so a transient\n # failure doesn't pad the doctor run by 30+ seconds.\n cfg = _BotoConfig(\n connect_timeout=5,\n read_timeout=10,\n retries={\"max_attempts\": 1},\n )\n client = boto3.client(\"bedrock\", region_name=region, config=cfg)\n resp = client.list_foundation_models()\n n = len(resp.get(\"modelSummaries\", []))\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({auth_var}, {region}, {n} models)\", Colors.DIM))],\n [],\n )\n except ImportError:\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(boto3 not installed — {sys.executable} -m pip install boto3)\",\n Colors.DIM))],\n [f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\"],\n )\n except Exception as e:\n err_name = type(e).__name__\n return _ConnectivityResult(\n \"AWS Bedrock\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err_name}: {e})\", Colors.DIM))],\n [f\"AWS Bedrock: {err_name} — check IAM permissions for \"\n f\"bedrock:ListFoundationModels\"],\n )\n\n def _probe_azure_entra() -> _ConnectivityResult:\n \"\"\"Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``.\n\n Skipped unless the active config has ``model.provider:\n azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe\n the token-service / CLI chain for users on plain API-key Azure.\n\n Bounded by a 10s timeout (via\n :func:`agent.azure_identity_adapter.describe_active_credential`)\n so a slow token service can't pad the doctor run.\n \"\"\"\n label = \"Azure Foundry (Entra ID)\".ljust(28)\n try:\n from hermes_cli.config import load_config\n cfg = load_config()\n model_cfg = cfg.get(\"model\") if isinstance(cfg, dict) else {}\n if not isinstance(model_cfg, dict):\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n cfg_provider = str(model_cfg.get(\"provider\") or \"\").strip().lower()\n auth_mode = str(model_cfg.get(\"auth_mode\") or \"\").strip().lower()\n if cfg_provider != \"azure-foundry\" or auth_mode != \"entra_id\":\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n except Exception:\n return _ConnectivityResult(\"Azure Foundry (Entra ID)\", [], [])\n\n try:\n from agent.azure_identity_adapter import (\n EntraIdentityConfig,\n SCOPE_AI_AZURE_DEFAULT,\n describe_active_credential,\n has_azure_identity_installed,\n )\n except Exception as exc:\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"(adapter import failed: {exc})\", Colors.DIM))],\n [f\"Azure Foundry adapter import failed: {exc}\"],\n )\n\n if not has_azure_identity_installed():\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(\"(azure-identity not installed)\", Colors.DIM))],\n [f\"Install azure-identity: {sys.executable} -m pip install azure-identity\"],\n )\n\n base_url = str(model_cfg.get(\"base_url\") or \"\").strip()\n entra_cfg = model_cfg.get(\"entra\") or {}\n if not isinstance(entra_cfg, dict):\n entra_cfg = {}\n scope = (\n str(entra_cfg.get(\"scope\") or \"\").strip()\n or SCOPE_AI_AZURE_DEFAULT\n )\n config = EntraIdentityConfig(\n scope=scope,\n )\n info = describe_active_credential(config=config, timeout_seconds=10.0)\n if info.get(\"ok\"):\n env_sources = info.get(\"env_sources\") or []\n tag = \", \".join(env_sources) if env_sources else \"default credential chain\"\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"✓\", Colors.GREEN), label,\n color(f\"({tag}, scope={scope})\", Colors.DIM))],\n [],\n )\n err = info.get(\"error\") or \"credential chain exhausted\"\n hint = info.get(\"hint\") or (\n \"Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/\"\n \"AZURE_CLIENT_SECRET, or attach a managed identity to this VM.\"\n )\n return _ConnectivityResult(\n \"Azure Foundry (Entra ID)\",\n [(color(\"⚠\", Colors.YELLOW), label,\n color(f\"({err})\", Colors.DIM))],\n [f\"Azure Foundry Entra: {err}. {hint}\"],\n )\n\n # Build the probe submission list in display order\n _probes.append((\"OpenRouter API\", _probe_openrouter))\n _probes.append((\"Anthropic API\", _probe_anthropic))\n\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n for _entry in _APIKEY_PROVIDERS_CACHE:\n _pname, _env_vars, _default_url, _base_env, _supports = _entry\n # Capture loop vars by binding default args — without this, all closures\n # would share the final iteration's values and every probe would hit\n # the last provider's URL.\n _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url,\n b=_base_env, s=_supports:\n _probe_apikey_provider(p, e, u, b, s)))\n\n _probes.append((\"AWS Bedrock\", _probe_bedrock))\n _probes.append((\"Azure Foundry (Entra ID)\", _probe_azure_entra))\n\n # Print a single status line so users see something happening, then\n # fan out. ``\\r`` clears it once the first real result line lands.\n print(f\" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}\",\n end=\"\", flush=True)\n\n # Disable boto3's EC2 instance-metadata-service probe for the duration\n # of the parallel block. boto's default credential chain tries\n # 169.254.169.254 with a multi-second timeout when we're not on EC2,\n # which dominated the section's wall time before this fix\n # (~2s on a developer laptop, even with the rest parallelized).\n # Set on the parent thread before submitting work so the env-var\n # mutation never races with another worker. has_aws_credentials() in\n # the bedrock probe already gates on real env-var creds, so IMDS is\n # never the legitimate source for `hermes doctor`.\n _imds_prev = os.environ.get(\"AWS_EC2_METADATA_DISABLED\")\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = \"true\"\n try:\n # 8 workers is plenty — each probe is a single HTTP call plus a TLS\n # handshake. More than that wastes thread-startup cost and risks\n # noisy output if anything ever printed from inside a worker.\n with _futures.ThreadPoolExecutor(max_workers=8,\n thread_name_prefix=\"doctor-probe\") as _ex:\n _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes]\n _results = [_f.result() for _f in _futures_in_order]\n finally:\n if _imds_prev is None:\n os.environ.pop(\"AWS_EC2_METADATA_DISABLED\", None)\n else:\n os.environ[\"AWS_EC2_METADATA_DISABLED\"] = _imds_prev\n\n # Clear the \"Running …\" line and print all results in submission order.\n print(\"\\r\" + \" \" * 70 + \"\\r\", end=\"\")\n for _r in _results:\n for _glyph, _label, _detail in _r.lines:\n if _detail:\n print(f\" {_glyph} {_label} {_detail}\")\n else:\n print(f\" {_glyph} {_label}\")\n _issues_to_add = list(_r.issues)\n if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label):\n _issues_to_add = []\n for _issue in _issues_to_add:\n issues.append(_issue)\n\n _section(\"Tool Availability\")\n try:\n # Add project root to path for imports\n sys.path.insert(0, str(PROJECT_ROOT))\n from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS\n \n available, unavailable = check_tool_availability()\n available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable)\n \n for tid in available:\n info = TOOLSET_REQUIREMENTS.get(tid, {})\n check_ok(info.get(\"name\", tid), _doctor_tool_availability_detail(tid))\n \n for item in unavailable:\n env_vars = item.get(\"missing_vars\") or item.get(\"env_vars\") or []\n if env_vars:\n vars_str = \", \".join(env_vars)\n check_warn(item[\"name\"], f\"(missing {vars_str})\")\n else:\n check_warn(item[\"name\"], \"(system dependency not met)\")\n\n # Count disabled tools with API key requirements\n api_disabled = [u for u in unavailable if (u.get(\"missing_vars\") or u.get(\"env_vars\"))]\n if api_disabled:\n issues.append(\"Run 'hermes setup' to configure missing API keys for full tool access\")\n except Exception as e:\n check_warn(\"Could not check tool availability\", f\"({e})\")\n \n _section(\"Skills Hub\")\n hub_dir = HERMES_HOME / \"skills\" / \".hub\"\n if hub_dir.exists():\n check_ok(\"Skills Hub directory exists\")\n lock_file = hub_dir / \"lock.json\"\n if lock_file.exists():\n try:\n import json\n lock_data = json.loads(lock_file.read_text())\n count = len(lock_data.get(\"installed\", {}))\n check_ok(f\"Lock file OK ({count} hub-installed skill(s))\")\n except Exception:\n check_warn(\"Lock file\", \"(corrupted or unreadable)\")\n quarantine = hub_dir / \"quarantine\"\n q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0\n if q_count > 0:\n check_warn(f\"{q_count} skill(s) in quarantine\", \"(pending review)\")\n else:\n check_warn(\"Skills Hub directory not initialized\", \"(run: hermes skills list)\")\n\n from hermes_cli.config import get_env_value\n\n def _gh_authenticated() -> bool:\n \"\"\"Check if gh CLI is authenticated via token file or device flow.\"\"\"\n try:\n result = subprocess.run(\n [\"gh\", \"auth\", \"status\", \"--json\", \"authenticated\"],\n capture_output=True, timeout=10,\n )\n return result.returncode == 0\n except (FileNotFoundError, subprocess.TimeoutExpired):\n return False\n\n github_token = get_env_value(\"GITHUB_TOKEN\") or get_env_value(\"GH_TOKEN\")\n if github_token:\n check_ok(\"GitHub token configured (authenticated API access)\")\n elif _gh_authenticated():\n check_ok(\"GitHub authenticated via gh CLI\", \"(full API access — no GITHUB_TOKEN needed)\")\n else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n _section(\"Memory Provider\")\n _active_memory_provider = \"\"\n try:\n import yaml as _yaml\n _mem_cfg_path = HERMES_HOME / \"config.yaml\"\n if _mem_cfg_path.exists():\n with open(_mem_cfg_path, encoding=\"utf-8\") as _f:\n _raw_cfg = _yaml.safe_load(_f) or {}\n _active_memory_provider = (_raw_cfg.get(\"memory\") or {}).get(\"provider\", \"\")\n except Exception:\n pass\n\n if not _active_memory_provider:\n check_ok(\"Built-in memory active\", \"(no external provider configured — this is fine)\")\n elif _active_memory_provider == \"honcho\":\n try:\n from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path\n hcfg = HonchoClientConfig.from_global_config()\n _honcho_cfg_path = resolve_config_path()\n\n if not _honcho_cfg_path.exists():\n # Config file missing — but env var fallback may have resolved it.\n # Only warn if the config didn't actually resolve from env vars.\n if hcfg.api_key or hcfg.base_url:\n check_ok(\n \"Honcho configured via environment variables\",\n f\"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var\",\n )\n else:\n check_warn(\"Honcho config not found\", \"run: hermes memory setup\")\n elif not hcfg.enabled:\n check_info(f\"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)\")\n elif not (hcfg.api_key or hcfg.base_url):\n _fail_and_issue(\n \"Honcho API key or base URL not set\",\n \"run: hermes memory setup\",\n \"No Honcho API key — run 'hermes memory setup'\",\n issues,\n )\n else:\n from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client\n reset_honcho_client()\n try:\n get_honcho_client(hcfg)\n check_ok(\n \"Honcho connected\",\n f\"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}\",\n )\n except Exception as _e:\n _fail_and_issue(\"Honcho connection failed\", str(_e), f\"Honcho unreachable: {_e}\", issues)\n except ImportError:\n _fail_and_issue(\n \"honcho-ai not installed\",\n \"pip install honcho-ai\",\n \"Honcho is set as memory provider but honcho-ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Honcho check failed\", str(_e))\n elif _active_memory_provider == \"mem0\":\n try:\n from plugins.memory.mem0 import _load_config as _load_mem0_config\n mem0_cfg = _load_mem0_config()\n mem0_key = mem0_cfg.get(\"api_key\", \"\")\n if mem0_key:\n check_ok(\"Mem0 API key configured\")\n check_info(f\"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}\")\n else:\n _fail_and_issue(\n \"Mem0 API key not set\",\n \"(set MEM0_API_KEY in .env or run hermes memory setup)\",\n \"Mem0 is set as memory provider but API key is missing\",\n issues,\n )\n except ImportError:\n _fail_and_issue(\n \"Mem0 plugin not loadable\",\n \"pip install mem0ai\",\n \"Mem0 is set as memory provider but mem0ai is not installed\",\n issues,\n )\n except Exception as _e:\n check_warn(\"Mem0 check failed\", str(_e))\n else:\n # Generic check for other memory providers (openviking, hindsight, etc.)\n try:\n from plugins.memory import load_memory_provider\n _provider = load_memory_provider(_active_memory_provider)\n if _provider and _provider.is_available():\n check_ok(f\"{_active_memory_provider} provider active\")\n elif _provider:\n check_warn(f\"{_active_memory_provider} configured but not available\", \"run: hermes memory status\")\n else:\n check_warn(f\"{_active_memory_provider} plugin not found\", \"run: hermes memory setup\")\n except Exception as _e:\n check_warn(f\"{_active_memory_provider} check failed\", str(_e))\n\n try:\n from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists\n import re as _re\n\n named_profiles = [p for p in list_profiles() if not p.is_default]\n if named_profiles:\n _section(\"Profiles\")\n check_ok(f\"{len(named_profiles)} profile(s) found\")\n wrapper_dir = _get_wrapper_dir()\n for p in named_profiles:\n parts = []\n if p.gateway_running:\n parts.append(\"gateway running\")\n if p.model:\n parts.append(p.model[:30])\n if not (p.path / \"config.yaml\").exists():\n parts.append(\"⚠ missing config\")\n if not (p.path / \".env\").exists():\n parts.append(\"no .env\")\n wrapper = wrapper_dir / p.name\n if not wrapper.exists():\n parts.append(\"no alias\")\n status = \", \".join(parts) if parts else \"configured\"\n check_ok(f\" {p.name}: {status}\")\n\n # Check for orphan wrappers\n if wrapper_dir.is_dir():\n for wrapper in wrapper_dir.iterdir():\n if not wrapper.is_file():\n continue\n try:\n content = wrapper.read_text()\n if \"hermes -p\" in content:\n _m = _re.search(r\"hermes -p (\\S+)\", content)\n if _m and not profile_exists(_m.group(1)):\n check_warn(f\"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists\")\n except Exception:\n pass\n except ImportError:\n pass\n except Exception:\n pass\n\n print()\n remaining_issues = issues + manual_issues\n if should_fix and fixed_count > 0:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(f\" Fixed {fixed_count} issue(s).\", Colors.GREEN, Colors.BOLD), end=\"\")\n if remaining_issues:\n print(color(f\" {len(remaining_issues)} issue(s) require manual intervention.\", Colors.YELLOW, Colors.BOLD))\n else:\n print()\n print()\n if remaining_issues:\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n elif remaining_issues:\n print(color(\"─\" * 60, Colors.YELLOW))\n print(color(f\" Found {len(remaining_issues)} issue(s) to address:\", Colors.YELLOW, Colors.BOLD))\n print()\n for i, issue in enumerate(remaining_issues, 1):\n print(f\" {i}. {issue}\")\n print()\n if not should_fix:\n print(color(\" Tip: run 'hermes doctor --fix' to auto-fix what's possible.\", Colors.DIM))\n else:\n print(color(\"─\" * 60, Colors.GREEN))\n print(color(\" All checks passed! 🎉\", Colors.GREEN, Colors.BOLD))\n \n print()\n"}}, "hermes_cli/models.py": {"148f82ed297ad19fd57efa72b9b725989bdf6db7": {"t": 1785746533.310476, "n": 198699, "h": "8d075b1114052a2c", "c": "\"\"\"\nCanonical model catalogs and lightweight validation helpers.\n\nAdd, remove, or reorder entries here — both `hermes setup` and\n`hermes` provider-selection will pick up the change automatically.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport os\nimport re\nimport urllib.parse\nimport urllib.request\nimport urllib.error\nimport time\nfrom difflib import get_close_matches\nfrom pathlib import Path\nfrom typing import Any, NamedTuple, Optional\n\nfrom hermes_cli import __version__ as _HERMES_VERSION\nfrom hermes_cli.urllib_security import open_credentialed_url\n\n# Identify ourselves so endpoints fronted by Cloudflare's Browser Integrity\n# Check (error 1010) don't reject the default ``Python-urllib/*`` signature.\n_HERMES_USER_AGENT = f\"hermes-cli/{_HERMES_VERSION}\"\n\nCOPILOT_BASE_URL = \"https://api.githubcopilot.com\"\nCOPILOT_MODELS_URL = f\"{COPILOT_BASE_URL}/models\"\nCOPILOT_EDITOR_VERSION = \"vscode/1.104.1\"\nCOPILOT_REASONING_EFFORTS_GPT5 = [\"minimal\", \"low\", \"medium\", \"high\"]\nCOPILOT_REASONING_EFFORTS_O_SERIES = [\"low\", \"medium\", \"high\"]\n\ndef _urlopen_model_catalog_request(req: urllib.request.Request, *, timeout: float):\n \"\"\"Open catalog requests without forwarding headers across origins.\"\"\"\n return open_credentialed_url(req, timeout=timeout)\n\n\n# Fallback OpenRouter snapshot used when the live catalog is unavailable.\n# (model_id, display description shown in menus)\nOPENROUTER_MODELS: list[tuple[str, str]] = [\n # Anthropic\n (\"anthropic/claude-fable-5\", \"\"),\n (\"anthropic/claude-opus-5\", \"\"),\n (\"anthropic/claude-opus-5-fast\", \"2x price, higher output speed\"),\n (\"anthropic/claude-opus-4.8\", \"\"),\n (\"anthropic/claude-opus-4.8-fast\", \"2x price, higher output speed\"),\n (\"anthropic/claude-sonnet-5\", \"\"),\n (\"anthropic/claude-haiku-4.5\", \"\"),\n # OpenAI\n (\"openai/gpt-5.6-sol\", \"\"),\n (\"openai/gpt-5.6-sol-pro\", \"\"),\n (\"openai/gpt-5.6-terra\", \"\"),\n (\"openai/gpt-5.6-terra-pro\", \"\"),\n (\"openai/gpt-5.6-luna\", \"\"),\n (\"openai/gpt-5.6-luna-pro\", \"\"),\n (\"openai/gpt-5.5\", \"\"),\n (\"openai/gpt-5.5-pro\", \"\"),\n (\"openai/gpt-5.4-mini\", \"\"),\n # Google\n (\"google/gemini-3-pro-preview\", \"\"),\n (\"google/gemini-3.1-pro-preview\", \"\"),\n (\"google/gemini-3.5-flash\", \"\"),\n # xAI\n (\"x-ai/grok-4.5\", \"\"),\n # DeepSeek\n (\"deepseek/deepseek-v4-pro\", \"\"),\n (\"deepseek/deepseek-v4-flash\", \"\"),\n # Qwen\n (\"qwen/qwen3.7-max\", \"\"),\n (\"qwen/qwen3.7-plus\", \"\"),\n (\"qwen/qwen3.6-35b-a3b\", \"\"),\n # MoonshotAI\n (\"moonshotai/kimi-k3\", \"recommended\"),\n # MiniMax\n (\"minimax/minimax-m3\", \"\"),\n # Z-AI\n (\"z-ai/glm-5.2\", \"default\"),\n (\"z-ai/glm-5.1\", \"\"),\n # Xiaomi\n (\"xiaomi/mimo-v2.5-pro\", \"\"),\n # Tencent\n (\"tencent/hy3\", \"\"),\n # StepFun\n (\"stepfun/step-3.7-flash\", \"\"),\n # NVIDIA\n (\"nvidia/nemotron-3-super-120b-a12b\", \"\"),\n # Sakana\n (\"sakana/fugu-ultra\", \"\"),\n # OpenRouter routers\n (\"openrouter/pareto-code\", \"auto-routes to cheapest coder meeting openrouter.min_coding_score\"),\n # Free tier\n (\"openrouter/elephant-alpha\", \"free\"),\n (\"poolside/laguna-m.1:free\", \"free\"),\n (\"tencent/hy3:free\", \"free\"),\n (\"nvidia/nemotron-3-super-120b-a12b:free\", \"free\"),\n (\"nvidia/nemotron-3-ultra-550b-a55b:free\", \"free\"),\n (\"inclusionai/ring-2.6-1t:free\", \"free\"),\n]\n\n_openrouter_catalog_cache: list[tuple[str, str]] | None = None\n\n\n\n\ndef _codex_curated_models() -> list[str]:\n \"\"\"Derive the openai-codex curated list from codex_models.py.\n\n Single source of truth: DEFAULT_CODEX_MODELS + forward-compat synthesis.\n This keeps the gateway /model picker in sync with the CLI `hermes model`\n flow without maintaining a separate static list.\n \"\"\"\n from hermes_cli.codex_models import DEFAULT_CODEX_MODELS, _add_forward_compat_models\n return _add_forward_compat_models(list(DEFAULT_CODEX_MODELS))\n\n\n# Static fallback for xAI when the models.dev disk cache is empty (fresh\n# install, offline first run, etc.). Mirrors the xAI-direct model IDs from\n# $HERMES_HOME/models_dev_cache.json as of 2026-04-28. Whenever xAI renames\n# or retires a model, the disk cache picks it up on the next refresh and the\n# fallback here only matters until that refresh lands.\n#\n# Models retired by xAI on May 15, 2026 are excluded — see\n# https://docs.x.ai/developers/migration/may-15-retirement\n# (grok-4, grok-4-0709, grok-4-fast{,-reasoning,-non-reasoning},\n# grok-4-1-fast{,-reasoning,-non-reasoning}, grok-code-fast-1 → grok-4.3).\n_XAI_STATIC_FALLBACK: list[str] = [\n \"grok-build-0.1\",\n \"grok-4.5\",\n \"grok-4.3\",\n \"grok-4.20-0309-reasoning\",\n \"grok-4.20-0309-non-reasoning\",\n \"grok-4.20-multi-agent-0309\",\n]\n\n# Callable via xAI OAuth but omitted from models.dev and /v1/models listings.\n_XAI_CURATED_EXTRAS: list[str] = [\n \"grok-4.5\", # GA 2026-07 — kept until the models.dev disk cache refreshes\n \"grok-composer-2.5-fast\",\n]\n\n\n_XAI_TOP_MODEL = \"grok-build-0.1\"\n\n\ndef _xai_promote_top(ids: list[str]) -> list[str]:\n \"\"\"Pin the headline xAI model to the top of the curated list.\"\"\"\n if _XAI_TOP_MODEL in ids:\n return [_XAI_TOP_MODEL] + [m for m in ids if m != _XAI_TOP_MODEL]\n return ids\n\n\ndef _xai_merge_curated_extras(ids: list[str]) -> list[str]:\n \"\"\"Append Hermes-curated xAI models that are missing from models.dev.\"\"\"\n out = list(ids)\n for extra in _XAI_CURATED_EXTRAS:\n if extra in out:\n continue\n # Keep the headline model pinned; slot extras immediately after it.\n insert_at = 1 if out and out[0] == _XAI_TOP_MODEL else len(out)\n out.insert(insert_at, extra)\n return out\n\n\ndef _xai_curated_models() -> list[str]:\n \"\"\"Derive the xAI-direct curated list from models.dev disk cache.\n\n Reads $HERMES_HOME/models_dev_cache.json directly (no network) so this\n runs at import time without blocking. Falls back to ``_XAI_STATIC_FALLBACK``\n when the cache is empty or unreadable. Hermes refreshes the cache from\n https://models.dev/api.json on normal use, so this list self-heals as\n xAI renames models.\n\n Mirrors ``_codex_curated_models()``'s role for openai-codex.\n \"\"\"\n try:\n from agent.models_dev import _load_disk_cache\n data = _load_disk_cache()\n xai = data.get(\"xai\") if isinstance(data, dict) else None\n models = xai.get(\"models\") if isinstance(xai, dict) else None\n if isinstance(models, dict) and models:\n ids = [mid for mid in models.keys() if isinstance(mid, str)]\n if ids:\n return _xai_merge_curated_extras(_xai_promote_top(sorted(ids)))\n except Exception:\n # Any failure (missing file, malformed JSON, import error)\n # falls through to the static list.\n pass\n return _xai_merge_curated_extras(list(_XAI_STATIC_FALLBACK))\n\n\n_PROVIDER_MODELS: dict[str, list[str]] = {\n \"moa\": [\"default\"],\n \"nous\": [\n # Anthropic\n \"anthropic/claude-fable-5\",\n \"anthropic/claude-opus-5\",\n \"anthropic/claude-opus-4.8\",\n \"anthropic/claude-sonnet-5\",\n \"anthropic/claude-haiku-4.5\",\n # OpenAI\n \"openai/gpt-5.6-sol\",\n \"openai/gpt-5.6-sol-pro\",\n \"openai/gpt-5.6-terra\",\n \"openai/gpt-5.6-terra-pro\",\n \"openai/gpt-5.6-luna\",\n \"openai/gpt-5.6-luna-pro\",\n \"openai/gpt-5.5\",\n \"openai/gpt-5.5-pro\",\n \"openai/gpt-5.4-mini\",\n # Google\n \"google/gemini-3-pro-preview\",\n \"google/gemini-3.1-pro-preview\",\n \"google/gemini-3.5-flash\",\n # xAI\n \"x-ai/grok-4.5\",\n # DeepSeek\n \"deepseek/deepseek-v4-pro\",\n \"deepseek/deepseek-v4-flash\",\n # Qwen\n \"qwen/qwen3.7-max\",\n \"qwen/qwen3.7-plus\",\n \"qwen/qwen3.6-35b-a3b\",\n # MoonshotAI\n \"moonshotai/kimi-k3\",\n # MiniMax\n \"minimax/minimax-m3\",\n # Z-AI\n \"z-ai/glm-5.2\",\n \"z-ai/glm-5.1\",\n # Xiaomi\n \"xiaomi/mimo-v2.5-pro\",\n # Tencent\n \"tencent/hy3\",\n # StepFun\n \"stepfun/step-3.7-flash\",\n # NVIDIA\n \"nvidia/nemotron-3-super-120b-a12b\",\n # Sakana\n \"sakana/fugu-ultra\",\n ],\n # Native OpenAI Chat Completions (api.openai.com). Used by /model counts and\n # provider_model_ids fallback when /v1/models is unavailable.\n \"openai\": [\n \"gpt-5.4\",\n \"gpt-5.4-mini\",\n \"gpt-5-mini\",\n \"gpt-5.3-codex\",\n \"gpt-5.2-codex\",\n \"gpt-4.1\",\n \"gpt-4o\",\n \"gpt-4o-mini\",\n ],\n \"openai-api\": [\n \"gpt-5.6-sol\",\n \"gpt-5.6-sol-pro\",\n \"gpt-5.6-terra\",\n \"gpt-5.6-terra-pro\",\n \"gpt-5.6-luna\",\n \"gpt-5.6-luna-pro\",\n \"gpt-5.5\",\n \"gpt-5.5-pro\",\n \"gpt-5.4\",\n \"gpt-5.4-mini\",\n \"gpt-5.4-nano\",\n \"gpt-5-mini\",\n \"gpt-5.3-codex\",\n \"gpt-4.1\",\n \"gpt-4o\",\n \"gpt-4o-mini\",\n ],\n \"openai-codex\": _codex_curated_models(),\n \"xai-oauth\": _xai_curated_models(),\n \"copilot-acp\": [\n \"copilot-acp\",\n ],\n \"copilot\": [\n \"gpt-5.4\",\n \"gpt-5.4-mini\",\n \"gpt-5-mini\",\n \"gpt-5.3-codex\",\n \"gpt-5.2-codex\",\n \"gpt-4.1\",\n \"gpt-4o\",\n \"gpt-4o-mini\",\n \"claude-sonnet-4.6\",\n \"claude-sonnet-5\",\n \"claude-sonnet-4\",\n \"claude-sonnet-4.5\",\n \"claude-haiku-4.5\",\n \"gemini-3.1-pro-preview\",\n \"gemini-3-pro-preview\",\n \"gemini-3-flash-preview\",\n \"gemini-2.5-pro\",\n ],\n \"gemini\": [\n \"gemini-3.1-pro-preview\",\n \"gemini-3-pro-preview\",\n \"gemini-3.5-flash\",\n \"gemini-3.1-flash-lite-preview\",\n ],\n \"zai\": [\n \"glm-5.2\",\n \"glm-5.1\",\n \"glm-5\",\n \"glm-5v-turbo\",\n \"glm-5-turbo\",\n \"glm-4.7\",\n \"glm-4.5\",\n \"glm-4.5-flash\",\n ],\n \"xai\": _xai_curated_models(),\n \"nvidia\": [\n # NVIDIA flagship reasoning models\n \"nvidia/nemotron-3-ultra-550b-a55b\",\n \"nvidia/nemotron-3-super-120b-a12b\",\n \"nvidia/nemotron-3-nano-omni-30b-a3b-reasoning\",\n # Third-party agentic models hosted on build.nvidia.com\n # (map to OpenRouter defaults — users get familiar picks on NIM)\n \"z-ai/glm-5.2\",\n \"moonshotai/kimi-k2.6\",\n \"minimaxai/minimax-m3\",\n ],\n \"kimi-coding\": [\n \"kimi-k3\",\n \"kimi-k2.7-code\",\n \"kimi-k2.6\",\n \"kimi-k2.5\",\n \"kimi-for-coding\",\n \"kimi-for-coding-highspeed\",\n \"kimi-k2-thinking\",\n \"kimi-k2-thinking-turbo\",\n \"kimi-k2-turbo-preview\",\n \"kimi-k2-0905-preview\",\n ],\n \"kimi-coding-cn\": [\n \"kimi-k3\",\n \"kimi-k2.7-code\",\n \"kimi-k2.7-code-highspeed\",\n \"kimi-k2.6\",\n \"kimi-k2.5\",\n \"kimi-k2-thinking\",\n \"kimi-k2-turbo-preview\",\n \"kimi-k2-0905-preview\",\n ],\n \"stepfun\": [\n \"step-3.5-flash\",\n \"step-3.5-flash-2603\",\n ],\n \"moonshot\": [\n \"kimi-k3\",\n \"kimi-k2.6\",\n \"kimi-k2.5\",\n \"kimi-k2-thinking\",\n \"kimi-k2-turbo-preview\",\n \"kimi-k2-0905-preview\",\n ],\n \"minimax\": [\n \"MiniMax-M3\",\n \"MiniMax-M2.7\",\n \"MiniMax-M2.5\",\n \"MiniMax-M2.1\",\n \"MiniMax-M2\",\n ],\n \"minimax-oauth\": [\n \"MiniMax-M3\",\n \"MiniMax-M2.7\",\n \"MiniMax-M2.7-highspeed\",\n ],\n \"minimax-cn\": [\n \"MiniMax-M3\",\n \"MiniMax-M2.7\",\n \"MiniMax-M2.5\",\n \"MiniMax-M2.1\",\n \"MiniMax-M2\",\n ],\n \"anthropic\": [\n \"claude-fable-5\",\n \"claude-sonnet-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n \"claude-opus-4-5-20251101\",\n \"claude-sonnet-4-5-20250929\",\n \"claude-opus-4-20250514\",\n \"claude-sonnet-4-20250514\",\n \"claude-haiku-4-5-20251001\",\n ],\n \"deepseek\": [\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n ],\n \"xiaomi\": [\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-pro\",\n \"mimo-v2-omni\",\n \"mimo-v2-flash\",\n ],\n \"tencent-tokenhub\": [\n \"hy3-preview\",\n ],\n \"arcee\": [\n \"trinity-large-thinking\",\n \"trinity-large-preview\",\n \"trinity-mini\",\n ],\n \"gmi\": [\n \"zai-org/GLM-5.1-FP8\",\n \"deepseek-ai/DeepSeek-V3.2\",\n \"moonshotai/Kimi-K2.5\",\n \"google/gemini-3.1-flash-lite-preview\",\n \"anthropic/claude-sonnet-5\",\n \"anthropic/claude-sonnet-4.6\",\n \"openai/gpt-5.4\",\n ],\n \"opencode-zen\": [\n \"kimi-k2.5\",\n \"kimi-k2.6\",\n \"gpt-5.5\",\n \"gpt-5.5-pro\",\n \"gpt-5.4-pro\",\n \"gpt-5.4\",\n \"gpt-5.4-mini\",\n \"gpt-5.4-nano\",\n \"gpt-5.3-codex\",\n \"gpt-5.3-codex-spark\",\n \"gpt-5.2\",\n \"gpt-5.2-codex\",\n \"gpt-5.1\",\n \"gpt-5.1-codex\",\n \"gpt-5.1-codex-max\",\n \"gpt-5.1-codex-mini\",\n \"gpt-5\",\n \"gpt-5-codex\",\n \"gpt-5-nano\",\n \"claude-fable-5\",\n \"claude-sonnet-5\",\n \"claude-opus-4-8\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-opus-4-5\",\n \"claude-opus-4-1\",\n \"claude-sonnet-4-6\",\n \"claude-sonnet-4-5\",\n \"claude-sonnet-4\",\n \"claude-haiku-4-5\",\n \"gemini-3.5-flash\",\n \"gemini-3.1-pro\",\n \"gemini-3-flash\",\n \"minimax-m3\",\n \"minimax-m2.7\",\n \"minimax-m2.5\",\n \"glm-5.2\",\n \"glm-5.1\",\n \"glm-5\",\n \"kimi-k2.7-code\",\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"deepseek-v4-flash-free\",\n \"qwen3.7-plus\",\n \"qwen3.6-plus\",\n \"qwen3.5-plus\",\n \"grok-build-0.1\",\n \"big-pickle\",\n \"mimo-v2.5-free\",\n \"north-mini-code-free\",\n \"nemotron-3-ultra-free\",\n ],\n \"opencode-go\": [\n \"kimi-k3\",\n \"kimi-k2.7-code\",\n \"kimi-k2.6\",\n \"kimi-k2.5\",\n \"glm-5.2\",\n \"glm-5.1\",\n \"glm-5\",\n \"mimo-v2.5-pro\",\n \"mimo-v2.5\",\n \"mimo-v2-pro\",\n \"mimo-v2-omni\",\n \"minimax-m3\",\n \"minimax-m2.7\",\n \"minimax-m2.5\",\n \"deepseek-v4-pro\",\n \"deepseek-v4-flash\",\n \"qwen3.7-max\",\n \"qwen3.7-plus\",\n \"qwen3.6-plus\",\n \"qwen3.5-plus\",\n ],\n \"kilocode\": [\n \"anthropic/claude-opus-4.6\",\n \"anthropic/claude-sonnet-4.6\",\n \"openai/gpt-5.4\",\n \"google/gemini-3-pro-preview\",\n \"google/gemini-3-flash-preview\",\n ],\n # Alibaba DashScope Coding platform (coding-intl) — default endpoint.\n # Supports Qwen models + third-party providers (GLM, Kimi, MiniMax).\n # Users with classic DashScope keys should override DASHSCOPE_BASE_URL\n # to https://dashscope-intl.aliyuncs.com/compatible-mode/v1 (OpenAI-compat)\n # or https://dashscope-intl.aliyuncs.com/apps/anthropic (Anthropic-compat).\n \"alibaba\": [\n \"qwen3.7-max\",\n \"qwen3.7-plus\",\n \"qwen3.6-plus\",\n \"kimi-k2.5\",\n \"qwen3.5-plus\",\n \"qwen3-coder-plus\",\n \"qwen3-coder-next\",\n # Third-party models available on coding-intl\n \"glm-5\",\n \"glm-4.7\",\n \"MiniMax-M2.5\",\n ],\n # Alibaba Coding Plan — same platform as alibaba (DashScope coding-intl),\n # separate provider ID with its own base_url_env_var.\n \"alibaba-coding-plan\": [\n \"qwen3.7-plus\",\n \"qwen3.6-plus\",\n \"qwen3.5-plus\",\n \"qwen3-max-2026-01-23\",\n \"qwen3-coder-plus\",\n \"qwen3-coder-next\",\n \"kimi-k2.5\",\n \"glm-5\",\n \"glm-4.7\",\n \"MiniMax-M2.5\",\n ],\n # Curated HF model list — only agentic models that map to OpenRouter defaults.\n \"huggingface\": [\n \"moonshotai/Kimi-K2.5\",\n \"Qwen/Qwen3.5-397B-A17B\",\n \"Qwen/Qwen3.5-35B-A3B\",\n \"deepseek-ai/DeepSeek-V3.2\",\n \"MiniMaxAI/MiniMax-M2.5\",\n \"zai-org/GLM-5\",\n \"XiaomiMiMo/MiMo-V2-Flash\",\n \"moonshotai/Kimi-K2-Thinking\",\n \"moonshotai/Kimi-K2.6\",\n ],\n # AWS Bedrock — static fallback list used when dynamic discovery is\n # unavailable (no boto3, no credentials, or API error). The agent\n # prefers live discovery via ListFoundationModels + ListInferenceProfiles.\n # Use inference profile IDs (us.*) since most models require them.\n \"bedrock\": [\n \"us.anthropic.claude-sonnet-5\",\n \"us.anthropic.claude-sonnet-4-6\",\n \"us.anthropic.claude-opus-4-6-v1\",\n \"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\",\n \"us.amazon.nova-pro-v1:0\",\n \"us.amazon.nova-lite-v1:0\",\n \"us.amazon.nova-micro-v1:0\",\n \"deepseek.v3.2\",\n \"us.meta.llama4-maverick-17b-instruct-v1:0\",\n \"us.meta.llama4-scout-17b-instruct-v1:0\",\n ],\n # Azure Foundry: user-provided endpoint and model.\n # Empty list because models depend on the endpoint configuration.\n \"azure-foundry\": [],\n # Google Vertex AI — static curated list. Vertex's OpenAI-compatible\n # endpoint has no /models listing route, so without this entry the\n # /model picker only ever shows the currently-configured model.\n # Model IDs use the \"google/\" publisher prefix Vertex's openapi\n # endpoint expects (see hermes_cli/model_setup_flows.py).\n \"vertex\": [\n \"google/gemini-3.1-pro-preview\",\n \"google/gemini-3-pro-preview\",\n \"google/gemini-3.5-flash\",\n \"google/gemini-3-flash-preview\",\n \"google/gemini-3.1-flash-lite-preview\",\n ],\n \"novita\": [\n \"moonshotai/kimi-k2.5\",\n \"minimax/minimax-m2.7\",\n \"zai-org/glm-5\",\n \"deepseek/deepseek-v3-0324\",\n \"deepseek/deepseek-r1-0528\",\n \"qwen/qwen3-235b-a22b-fp8\",\n ],\n}\n\n# ---------------------------------------------------------------------------\n# Nous Portal free-model helper\n# ---------------------------------------------------------------------------\n# The Nous Portal models endpoint is the source of truth for which models\n# are currently offered (free or paid). We trust whatever it returns and\n# surface it to users as-is — no local allowlist filtering.\n\n\ndef _is_model_free(model_id: str, pricing: dict[str, dict[str, str]]) -> bool:\n \"\"\"Return True if *model_id* has zero-cost prompt AND completion pricing.\"\"\"\n p = pricing.get(model_id)\n if not p:\n return False\n try:\n return float(p.get(\"prompt\", \"1\")) == 0 and float(p.get(\"completion\", \"1\")) == 0\n except (TypeError, ValueError):\n return False\n\n\n# ---------------------------------------------------------------------------\n# Nous Portal account tier detection\n# ---------------------------------------------------------------------------\ndef is_nous_free_tier(account_info: dict[str, Any]) -> bool:\n \"\"\"Return True if the account info indicates a free (unpaid) tier.\n\n Prefer the Portal's explicit ``paid_service_access.allowed`` entitlement\n decision. Legacy payloads fall back to ``subscription.monthly_charge == 0``.\n Returns False when both signals are missing or unparseable.\n \"\"\"\n paid_access = account_info.get(\"paid_service_access\")\n if isinstance(paid_access, dict):\n allowed = paid_access.get(\"allowed\")\n if isinstance(allowed, bool):\n return not allowed\n paid = paid_access.get(\"paid_access\")\n if isinstance(paid, bool):\n return not paid\n\n sub = account_info.get(\"subscription\")\n if not isinstance(sub, dict):\n return False\n charge = sub.get(\"monthly_charge\")\n if charge is None:\n return False\n try:\n return float(charge) == 0\n except (TypeError, ValueError):\n return False\n\n\ndef partition_nous_models_by_tier(\n model_ids: list[str],\n pricing: dict[str, dict[str, str]],\n free_tier: bool,\n) -> tuple[list[str], list[str]]:\n \"\"\"Split Nous models into (selectable, unavailable) based on user tier.\n\n For paid-tier users: all models are selectable, none unavailable.\n\n For free-tier users: only free models are selectable; paid models\n are returned as unavailable (shown grayed out in the menu).\n \"\"\"\n if not free_tier:\n return (model_ids, [])\n\n if not pricing:\n return (model_ids, []) # can't determine, show everything\n\n selectable: list[str] = []\n unavailable: list[str] = []\n for mid in model_ids:\n if _is_model_free(mid, pricing):\n selectable.append(mid)\n else:\n unavailable.append(mid)\n return (selectable, unavailable)\n\n\ndef union_with_portal_free_recommendations(\n curated_ids: list[str],\n pricing: dict[str, dict[str, str]],\n portal_base_url: str = \"\",\n *,\n force_refresh: bool = False,\n) -> tuple[list[str], dict[str, dict[str, str]]]:\n \"\"\"Augment curated list + pricing with the Portal's ``freeRecommendedModels``.\n\n The Portal's ``/api/nous/recommended-models`` endpoint advertises which\n models are free *right now* — independent of what the in-repo\n ``_PROVIDER_MODELS[\"nous\"]`` list happens to contain or whether the\n docs-hosted catalog manifest has been rebuilt since the last release.\n\n For free-tier users this is the source of truth: any model the Portal\n flags as free should be selectable, even if the user is running an\n older Hermes that doesn't ship that model in its hardcoded curated\n list. This function returns an augmented ``(model_ids, pricing)``\n pair where:\n\n * Portal free recommendations missing from ``curated_ids`` are\n appended after the curated list (so the in-repo curated models\n show first and Portal-only picks follow).\n * ``pricing`` gets a synthetic ``{\"prompt\": \"0\", \"completion\": \"0\"}``\n entry for any free recommendation missing from the live pricing\n map, so :func:`partition_nous_models_by_tier` keeps it.\n\n Failures (network, parse, missing field) are silent and degrade to\n returning the inputs unchanged.\n \"\"\"\n try:\n payload = fetch_nous_recommended_models(\n portal_base_url, force_refresh=force_refresh\n )\n except Exception:\n return (list(curated_ids), dict(pricing))\n\n free_block = payload.get(\"freeRecommendedModels\") if isinstance(payload, dict) else None\n if not isinstance(free_block, list) or not free_block:\n return (list(curated_ids), dict(pricing))\n\n portal_free_ids: list[str] = []\n for entry in free_block:\n name = _extract_model_name(entry)\n if name:\n portal_free_ids.append(name)\n if not portal_free_ids:\n return (list(curated_ids), dict(pricing))\n\n augmented_pricing = dict(pricing)\n free_synthetic = {\"prompt\": \"0\", \"completion\": \"0\"}\n for mid in portal_free_ids:\n if mid not in augmented_pricing:\n augmented_pricing[mid] = dict(free_synthetic)\n\n augmented_ids = list(curated_ids)\n seen = set(augmented_ids)\n # Append Portal free recommendations that aren't already curated, so the\n # in-repo curated (\"HA\") models show first and Portal-only picks follow.\n new_ones = [mid for mid in portal_free_ids if mid not in seen]\n if new_ones:\n augmented_ids = augmented_ids + new_ones\n\n return (augmented_ids, augmented_pricing)\n\n\ndef union_with_portal_paid_recommendations(\n curated_ids: list[str],\n pricing: dict[str, dict[str, str]],\n portal_base_url: str = \"\",\n *,\n force_refresh: bool = False,\n) -> tuple[list[str], dict[str, dict[str, str]]]:\n \"\"\"Augment curated list with the Portal's ``paidRecommendedModels``.\n\n Mirror of :func:`union_with_portal_free_recommendations` for paid-tier\n users. The Portal's ``/api/nous/recommended-models`` endpoint advertises\n which paid models are blessed *right now* — independent of what the\n in-repo ``_PROVIDER_MODELS[\"nous\"]`` list happens to contain or whether\n the docs-hosted catalog manifest has been rebuilt since the last release.\n\n For paid-tier users this lets newly-launched paid models surface in the\n picker even if the user is running an older Hermes that doesn't ship\n them in its hardcoded curated list. This function returns an augmented\n ``(model_ids, pricing)`` pair where:\n\n * Portal paid recommendations missing from ``curated_ids`` are\n appended after the curated list (so the in-repo curated models\n show first and Portal-only picks follow).\n * ``pricing`` is left untouched — we deliberately do NOT synthesize\n pricing entries for paid models. Live pricing is fetched separately\n via :func:`get_pricing_for_provider`; if the live endpoint hasn't\n published pricing yet, the picker shows a blank price column rather\n than fabricating numbers. (The free helper synthesizes ``$0`` so\n :func:`partition_nous_models_by_tier` keeps free models selectable;\n no equivalent gating applies on the paid side, so synthesis would\n only mislead the user.)\n\n Failures (network, parse, missing field) are silent and degrade to\n returning the inputs unchanged — never block the picker on a\n Portal-side hiccup.\n \"\"\"\n try:\n payload = fetch_nous_recommended_models(\n portal_base_url, force_refresh=force_refresh\n )\n except Exception:\n return (list(curated_ids), dict(pricing))\n\n paid_block = payload.get(\"paidRecommendedModels\") if isinstance(payload, dict) else None\n if not isinstance(paid_block, list) or not paid_block:\n return (list(curated_ids), dict(pricing))\n\n portal_paid_ids: list[str] = []\n for entry in paid_block:\n name = _extract_model_name(entry)\n if name:\n portal_paid_ids.append(name)\n if not portal_paid_ids:\n return (list(curated_ids), dict(pricing))\n\n augmented_ids = list(curated_ids)\n seen = set(augmented_ids)\n # Append Portal paid recommendations that aren't already curated, so the\n # in-repo curated (\"HA\") models show first and Portal-only picks follow.\n new_ones = [mid for mid in portal_paid_ids if mid not in seen]\n if new_ones:\n augmented_ids = augmented_ids + new_ones\n\n return (augmented_ids, dict(pricing))\n\n\n# ---------------------------------------------------------------------------\n# TTL cache for free-tier detection — avoids repeated API calls within a\n# session while still picking up upgrades quickly.\n# ---------------------------------------------------------------------------\n_FREE_TIER_CACHE_TTL: int = 180 # seconds (3 minutes)\n_free_tier_cache: tuple[bool, float] | None = None # (result, timestamp)\n\n\ndef check_nous_free_tier(*, force_fresh: bool = False) -> bool:\n \"\"\"Check if the current Nous Portal user is on a free (unpaid) tier.\n\n Results are cached for ``_FREE_TIER_CACHE_TTL`` seconds to avoid\n hitting the Portal API on every call. The cache is short-lived so\n that an account upgrade is reflected within a few minutes.\n\n Returns True only when entitlement is known to be free. Unknown/error\n states return False so this compatibility wrapper does not block users.\n \"\"\"\n global _free_tier_cache\n now = time.monotonic()\n if not force_fresh and _free_tier_cache is not None:\n cached_result, cached_at = _free_tier_cache\n if now - cached_at < _FREE_TIER_CACHE_TTL:\n return cached_result\n\n try:\n from hermes_cli.nous_account import get_nous_portal_account_info\n\n account_info = get_nous_portal_account_info(force_fresh=force_fresh)\n result = account_info.is_free_tier\n _free_tier_cache = (result, now)\n return result\n except Exception:\n _free_tier_cache = (False, now)\n return False # default to paid on error — don't block users\n\n\n# ---------------------------------------------------------------------------\n# Nous Portal recommended models\n#\n# The Portal publishes a curated list of suggested models (separated into\n# paid and free tiers) plus dedicated recommendations for compaction (text\n# summarisation / auxiliary) and vision tasks. We fetch it once per process\n# with a TTL cache so callers can ask \"what's the best aux model right now?\"\n# without hitting the network on every lookup.\n#\n# Shape of the response (fields we care about):\n# {\n# \"paidRecommendedModels\": [ {modelName, ...}, ... ],\n# \"freeRecommendedModels\": [ {modelName, ...}, ... ],\n# \"paidRecommendedCompactionModel\": {modelName, ...} | null,\n# \"paidRecommendedVisionModel\": {modelName, ...} | null,\n# \"freeRecommendedCompactionModel\": {modelName, ...} | null,\n# \"freeRecommendedVisionModel\": {modelName, ...} | null,\n# }\n# ---------------------------------------------------------------------------\n\nNOUS_RECOMMENDED_MODELS_PATH = \"/api/nous/recommended-models\"\n_NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)\n# (result_dict, timestamp) keyed by portal_base_url so staging vs prod don't collide.\n_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}\n\n\ndef _nous_recommended_disk_path() -> \"Path\":\n \"\"\"Disk path for the persisted recommended-models cache.\"\"\"\n from hermes_constants import get_hermes_home\n return get_hermes_home() / \"cache\" / \"nous_recommended_cache.json\"\n\n\ndef _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:\n \"\"\"Return the last-known-good payload for ``base`` from disk, or None.\n\n The disk file is a JSON object keyed by portal base URL so staging and\n prod don't collide:\n ``{\"\": {\"data\": {...}, \"ts\": }}``.\n \"\"\"\n try:\n with open(_nous_recommended_disk_path(), encoding=\"utf-8\") as fh:\n blob = json.load(fh)\n except (OSError, json.JSONDecodeError):\n return None\n if not isinstance(blob, dict):\n return None\n entry = blob.get(base)\n if not isinstance(entry, dict):\n return None\n data = entry.get(\"data\")\n return data if isinstance(data, dict) and data else None\n\n\ndef _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:\n \"\"\"Persist ``data`` as the last-known-good payload for ``base``.\n\n Merges into any existing per-base map, then writes atomically. Failures\n are non-fatal (logged at debug) — the in-process cache still works.\n \"\"\"\n if not data:\n return\n path = _nous_recommended_disk_path()\n try:\n try:\n with open(path, encoding=\"utf-8\") as fh:\n blob = json.load(fh)\n if not isinstance(blob, dict):\n blob = {}\n except (OSError, json.JSONDecodeError):\n blob = {}\n blob[base] = {\"data\": data, \"ts\": time.time()}\n path.parent.mkdir(parents=True, exist_ok=True)\n tmp = path.with_suffix(path.suffix + \".tmp\")\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n json.dump(blob, fh, indent=2)\n fh.write(\"\\n\")\n os.replace(tmp, path)\n except OSError as exc:\n import logging\n logging.getLogger(__name__).debug(\n \"nous recommended-models disk cache write failed: %s\", exc\n )\n\n\ndef fetch_nous_recommended_models(\n portal_base_url: str = \"\",\n timeout: float = 5.0,\n *,\n force_refresh: bool = False,\n) -> dict[str, Any]:\n \"\"\"Fetch the Nous Portal's curated recommended-models payload.\n\n Hits ``/api/nous/recommended-models``. The endpoint is public —\n no auth is required. Results are cached per portal URL for\n ``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass\n ``force_refresh=True`` to bypass the in-process cache.\n\n A successful live fetch is also persisted to a per-base disk cache\n (``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.\n When the live fetch fails (network, parse, non-2xx) and the in-process\n cache is empty, the disk copy is returned instead of ``{}`` — so a\n transient Portal hiccup no longer silently drops the free/paid model\n recommendations from the picker. Self-heals on the next successful fetch.\n\n Returns the parsed JSON dict, or ``{}`` only when neither the network nor\n any cache layer can supply data. Callers must treat missing/null fields\n as \"no recommendation\" and fall back to their own default.\n \"\"\"\n base = (portal_base_url or \"https://portal.nousresearch.com\").rstrip(\"/\")\n now = time.monotonic()\n cached = _nous_recommended_cache.get(base)\n if not force_refresh and cached is not None:\n payload, cached_at = cached\n if now - cached_at < _NOUS_RECOMMENDED_CACHE_TTL:\n return payload\n\n url = f\"{base}{NOUS_RECOMMENDED_MODELS_PATH}\"\n try:\n req = urllib.request.Request(\n url,\n headers={\"Accept\": \"application/json\"},\n )\n with _urlopen_model_catalog_request(req, timeout=timeout) as resp:\n data = json.loads(resp.read().decode())\n if not isinstance(data, dict):\n data = {}\n except Exception:\n data = {}\n\n if data:\n # Live fetch succeeded — refresh both cache layers.\n _nous_recommended_cache[base] = (data, now)\n _write_nous_recommended_disk(base, data)\n return data\n\n # Live fetch failed. Fall back to the last-known-good disk copy so a\n # transient Portal hiccup doesn't drop the recommendations entirely.\n disk = _read_nous_recommended_disk(base)\n if disk:\n _nous_recommended_cache[base] = (disk, now)\n return disk\n\n _nous_recommended_cache[base] = (data, now)\n return data\n\n\ndef _resolve_nous_portal_url() -> str:\n \"\"\"Best-effort lookup of the Portal base URL the user is authed against.\"\"\"\n try:\n from hermes_cli.auth import (\n DEFAULT_NOUS_PORTAL_URL,\n get_provider_auth_state,\n )\n state = get_provider_auth_state(\"nous\") or {}\n portal = str(state.get(\"portal_base_url\") or \"\").strip()\n if portal:\n return portal.rstrip(\"/\")\n return str(DEFAULT_NOUS_PORTAL_URL).rstrip(\"/\")\n except Exception:\n return \"https://portal.nousresearch.com\"\n\n\ndef _extract_model_name(entry: Any) -> Optional[str]:\n \"\"\"Pull the ``modelName`` field from a recommended-model entry, else None.\"\"\"\n if not isinstance(entry, dict):\n return None\n model_name = entry.get(\"modelName\")\n if isinstance(model_name, str) and model_name.strip():\n return model_name.strip()\n return None\n\n\ndef get_nous_recommended_aux_model(\n *,\n vision: bool = False,\n free_tier: Optional[bool] = None,\n portal_base_url: str = \"\",\n force_refresh: bool = False,\n) -> Optional[str]:\n \"\"\"Return the Portal's recommended model name for an auxiliary task.\n\n Picks the best field from the Portal's recommended-models payload:\n\n * ``vision=True`` → ``paidRecommendedVisionModel`` (paid tier) or\n ``freeRecommendedVisionModel`` (free tier)\n * ``vision=False`` → ``paidRecommendedCompactionModel`` or\n ``freeRecommendedCompactionModel``\n\n When ``free_tier`` is ``None`` (default) the user's tier is auto-detected\n via :func:`check_nous_free_tier`. Pass an explicit bool to bypass the\n detection — useful for tests or when the caller already knows the tier.\n\n For paid-tier users we prefer the paid recommendation but gracefully fall\n back to the free recommendation if the Portal returned ``null`` for the\n paid field (common during the staged rollout of new paid models).\n\n Returns ``None`` when every candidate is missing, null, or the fetch\n fails — callers should fall back to their own default (currently\n ``google/gemini-3-flash-preview``).\n \"\"\"\n base = portal_base_url or _resolve_nous_portal_url()\n payload = fetch_nous_recommended_models(base, force_refresh=force_refresh)\n if not payload:\n return None\n\n if free_tier is None:\n try:\n free_tier = check_nous_free_tier()\n except Exception:\n # On any detection error, assume paid — paid users see both fields\n # anyway so this is a safe default that maximises model quality.\n free_tier = False\n\n if vision:\n paid_key, free_key = \"paidRecommendedVisionModel\", \"freeRecommendedVisionModel\"\n else:\n paid_key, free_key = \"paidRecommendedCompactionModel\", \"freeRecommendedCompactionModel\"\n\n # Preference order:\n # free tier → free only\n # paid tier → paid, then free (if paid field is null)\n candidates = [free_key] if free_tier else [paid_key, free_key]\n for key in candidates:\n name = _extract_model_name(payload.get(key))\n if name:\n return name\n return None\n\n\n# ---------------------------------------------------------------------------\n# Canonical provider list — single source of truth for provider identity.\n# Every code path that lists, displays, or iterates providers derives from\n# this list: hermes model, /model, list_authenticated_providers.\n#\n# Fields:\n# slug — internal provider ID (used in config.yaml, --provider flag)\n# label — short display name\n# tui_desc — longer description for the `hermes model` interactive picker\n# ---------------------------------------------------------------------------\n\nclass ProviderEntry(NamedTuple):\n slug: str\n label: str\n tui_desc: str # detailed description for `hermes model` TUI\n\nCANONICAL_PROVIDERS: list[ProviderEntry] = [\n ProviderEntry(\"nous\", \"Nous Portal\", \"Nous Portal (Everything your agent needs, 300+ models with bundled tool use)\"),\n ProviderEntry(\"fireworks\", \"Fireworks AI\", \"Fireworks AI (OpenAI-compatible direct model API)\"),\n ProviderEntry(\"openrouter\", \"OpenRouter\", \"OpenRouter (Pay-per-use API aggregator)\"),\n ProviderEntry(\"moa\", \"Mixture of Agents\", \"Mixture of Agents (named presets; aggregator acts after reference models)\"),\n ProviderEntry(\"novita\", \"NovitaAI\", \"NovitaAI (Cloud: Model API, Agent Sandbox, GPU Cloud)\"),\n ProviderEntry(\"lmstudio\", \"LM Studio\", \"LM Studio (Local desktop app with built-in model server)\"),\n ProviderEntry(\"anthropic\", \"Anthropic\", \"Anthropic (Claude models via API key or Claude Code)\"),\n ProviderEntry(\"openai-codex\", \"OpenAI Codex\", \"OpenAI Codex (Codex CLI via ChatGPT subscription or API key)\"),\n ProviderEntry(\"openai-api\", \"OpenAI API\", \"OpenAI API (api.openai.com, API key)\"),\n ProviderEntry(\"alibaba\", \"Qwen Cloud\", \"Qwen Cloud / DashScope (Qwen + multi-provider)\"),\n ProviderEntry(\"xai-oauth\", \"xAI Grok OAuth (SuperGrok / Premium+)\", \"xAI Grok OAuth (SuperGrok / Premium+ subscription)\"),\n ProviderEntry(\"xiaomi\", \"Xiaomi MiMo\", \"Xiaomi MiMo (MiMo-V2.5 and V2 models: pro, omni, flash)\"),\n ProviderEntry(\"tencent-tokenhub\", \"Tencent TokenHub\", \"Tencent TokenHub (Hy3 Preview via tokenhub.tencentmaas.com)\"),\n ProviderEntry(\"nvidia\", \"NVIDIA NIM\", \"NVIDIA NIM (Nemotron models via build.nvidia.com or local NIM)\"),\n ProviderEntry(\"copilot\", \"GitHub Copilot\", \"GitHub Copilot (Uses GITHUB_TOKEN or gh auth token)\"),\n ProviderEntry(\"copilot-acp\", \"GitHub Copilot ACP\", \"GitHub Copilot ACP (Spawns copilot --acp --stdio)\"),\n ProviderEntry(\"huggingface\", \"Hugging Face\", \"Hugging Face Inference Providers\"),\n ProviderEntry(\"gemini\", \"Google AI Studio\", \"Google AI Studio (Native Gemini API)\"),\n ProviderEntry(\"vertex\", \"Google Vertex AI\", \"Google Vertex AI (Gemini via GCP; OAuth2 service account or ADC, GCP billing/quotas)\"),\n ProviderEntry(\"deepseek\", \"DeepSeek\", \"DeepSeek (V3, R1, coder, direct API)\"),\n ProviderEntry(\"xai\", \"xAI\", \"xAI Grok (Direct API)\"),\n ProviderEntry(\"zai\", \"Z.AI / GLM\", \"Z.AI / GLM (Zhipu direct API)\"),\n ProviderEntry(\"kimi-coding\", \"Kimi / Kimi Coding Plan\", \"Kimi Coding Plan (api.kimi.com & Moonshot API)\"),\n ProviderEntry(\"kimi-coding-cn\", \"Kimi / Moonshot (China)\", \"Kimi / Moonshot China (Domestic direct API)\"),\n ProviderEntry(\"stepfun\", \"StepFun Step Plan\", \"StepFun Step Plan (Agent / coding models via Step Plan API)\"),\n ProviderEntry(\"minimax\", \"MiniMax\", \"MiniMax (Global direct API)\"),\n ProviderEntry(\"minimax-oauth\", \"MiniMax (OAuth)\", \"MiniMax via OAuth browser login (Coding Plan, minimax.io)\"),\n ProviderEntry(\"minimax-cn\", \"MiniMax (China)\", \"MiniMax China (Domestic direct API)\"),\n ProviderEntry(\"ollama-cloud\", \"Ollama Cloud\", \"Ollama Cloud (Cloud-hosted open models, ollama.com)\"),\n ProviderEntry(\"arcee\", \"Arcee AI\", \"Arcee AI (Trinity models, direct API)\"),\n ProviderEntry(\"gmi\", \"GMI Cloud\", \"GMI Cloud (Multi-model direct API)\"),\n ProviderEntry(\"kilocode\", \"Kilo Code\", \"Kilo Code (Kilo Gateway API)\"),\n ProviderEntry(\"opencode-zen\", \"OpenCode Zen\", \"OpenCode Zen (Curated models, pay-as-you-go)\"),\n ProviderEntry(\"opencode-go\", \"OpenCode Go\", \"OpenCode Go (Open models subscription)\"),\n ProviderEntry(\"bedrock\", \"AWS Bedrock\", \"AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)\"),\n ProviderEntry(\"azure-foundry\", \"Azure Foundry\", \"Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)\"),\n ProviderEntry(\"qwen-oauth\", \"Qwen OAuth (Portal)\", \"Qwen OAuth (Reuses local Qwen CLI login)\"),\n]\n\n# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/\n# that is not already in the list above. Adding plugins/model-providers//\n# is sufficient to expose a new provider in the model picker, /model, and all\n# downstream consumers — no edits to this file needed.\n_canonical_slugs = {p.slug for p in CANONICAL_PROVIDERS}\ntry:\n from providers import list_providers as _list_providers_for_canonical\n for _pp in _list_providers_for_canonical():\n if _pp.name in _canonical_slugs:\n continue\n if _pp.auth_type in {\"oauth_device_code\", \"oauth_external\", \"external_process\", \"aws_sdk\", \"copilot\", \"vertex\"}:\n continue # non-api-key flows need bespoke picker UX; skip auto-inject\n _label = _pp.display_name or _pp.name\n _desc = _pp.description or f\"{_label} (direct API)\"\n CANONICAL_PROVIDERS.append(ProviderEntry(_pp.name, _label, _desc))\n _canonical_slugs.add(_pp.name)\nexcept Exception:\n pass\n\n# Derived dicts — used throughout the codebase\n_PROVIDER_LABELS = {p.slug: p.label for p in CANONICAL_PROVIDERS}\n_PROVIDER_LABELS[\"custom\"] = \"Custom endpoint\" # special case: not a named provider\n\n\n# ---------------------------------------------------------------------------\n# Provider groups — DISPLAY ONLY\n#\n# Some vendors expose several Hermes provider slugs (one per endpoint /\n# auth method: global API, China API, OAuth coding plan, ...). Listing every\n# slug as a top-level row in the interactive `hermes model` / setup wizard /\n# Telegram `/model` pickers makes that list long and noisy.\n#\n# These groups fold related slugs under one top-level row in INTERACTIVE\n# PICKERS only. They do NOT change ``CANONICAL_PROVIDERS``, slug identity,\n# the ``--provider`` flag, ``/model ``, or any typed path —\n# every member slug remains individually addressable. Grouping is a pure\n# display affordance; ``group_providers()`` is the single fold used by all\n# three picker surfaces so they stay consistent.\n#\n# group_id -> (display_label, group_description, [member_slug, ...])\n#\n# ``group_description`` is a short blurb shown on the collapsed top-level group\n# row in the interactive pickers (alongside the label). Member-specific detail\n# lives in each member's ``tui_desc`` and shows in the drill-down sub-picker.\n# Member order is the order shown inside the group submenu.\n# ---------------------------------------------------------------------------\nPROVIDER_GROUPS: dict[str, tuple[str, str, list[str]]] = {\n \"kimi\": (\"Kimi / Moonshot\", \"Coding Plan, Moonshot global & China endpoints\", [\"kimi-coding\", \"kimi-coding-cn\"]),\n \"minimax\": (\"MiniMax\", \"Global, OAuth Coding Plan & China endpoints\", [\"minimax\", \"minimax-oauth\", \"minimax-cn\"]),\n \"xai\": (\"xAI Grok\", \"Direct API or SuperGrok / Premium+ OAuth\", [\"xai\", \"xai-oauth\"]),\n \"google\": (\"Google Gemini\", \"Google AI Studio (API key)\", [\"gemini\"]),\n \"openai\": (\"OpenAI\", \"Codex CLI or direct OpenAI API\", [\"openai-codex\", \"openai-api\"]),\n \"qwen\": (\"Qwen\", \"Qwen Cloud / DashScope, Coding Plan & Qwen CLI OAuth\", [\"alibaba\", \"alibaba-coding-plan\", \"qwen-oauth\"]),\n \"opencode\": (\"OpenCode\", \"Zen pay-as-you-go or Go subscription\", [\"opencode-zen\", \"opencode-go\"]),\n \"copilot\": (\"GitHub Copilot\", \"GitHub token API or copilot --acp process\", [\"copilot\", \"copilot-acp\"]),\n}\n\n# Reverse index: member slug -> group_id. Built once at import.\n_SLUG_TO_GROUP: dict[str, str] = {\n slug: gid for gid, (_label, _desc, members) in PROVIDER_GROUPS.items() for slug in members\n}\n\n\ndef provider_group_for_slug(slug: str) -> str:\n \"\"\"Return the group_id a provider slug belongs to, or \"\" if ungrouped.\"\"\"\n return _SLUG_TO_GROUP.get(str(slug or \"\").strip().lower(), \"\")\n\n\ndef group_providers(slugs):\n \"\"\"Fold a flat ordered slug iterable into picker rows by provider group.\n\n DISPLAY ONLY. Used by every interactive picker (``hermes model``, the\n setup wizard, the Telegram ``/model`` keyboard) so grouping is identical\n across surfaces.\n\n Each returned row is a dict::\n\n {\"kind\": \"single\", \"slug\": } # ungrouped, or\n # 1-member group\n {\"kind\": \"group\", \"group_id\": , \"label\":