{"repo": "alvarobiano/hermes-agent", "files": {"hermes_cli/doctor.py": {"608a2936e1b87d0201c104e211c08a4b99c359fb": {"t": 1785746414.9369795, "n": 80033, "h": "ab8ba61dc0a0be75", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in {\"auto\", \"custom\", \"openrouter\"}:\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\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 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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in {\"\", \"0\", \"51200\"}:\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in {\"1\", \"true\", \"yes\", \"on\"}\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "2ec8d2b42ffecec2128f1004c0a8451378057d42": {"t": 1785763747.4913814, "n": 80033, "h": "ab8ba61dc0a0be75", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in {\"auto\", \"custom\", \"openrouter\"}:\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\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 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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in {\"\", \"0\", \"51200\"}:\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in {\"1\", \"true\", \"yes\", \"on\"}\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "657874460f8e2cff18e9940df1a987513278c43e": {"t": 1785763749.1846364, "n": 80033, "h": "56b255c83ff61705", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\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 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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "6e848f60eff7ddc8f48fb25411d052bdb3d1448a": {"t": 1785763751.2378976, "n": 80245, "h": "1519be1502ed2537", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "1dd0790654a842cb2677b357fafc9d0790716ab1": {"t": 1785763753.1159906, "n": 79828, "h": "af688f056273031c", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 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 if any(_alias in _dedicated_canonical for _alias in (_pp.aliases or ())):\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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "e612c3d6f00624868ce3f73bb6beaacfea36337f": {"t": 1785763755.2956312, "n": 79300, "h": "9ed2f5132cb7def5", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n\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 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 # 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\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 for _issue in _r.issues:\n issues.append(_issue)\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "03566e5124d106656f4152c1b084c233d9c07f3f": {"t": 1785763758.926648, "n": 74261, "h": "1dd16f35c9700a8b", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "cbce5e93fcb9a923ab71f45d2a0f0f172dd54967": {"t": 1785763760.8382704, "n": 71678, "h": "2b1d5838d5995873", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "e93bfc6c93bfa6f9edd02629a03f717fc29ce013": {"t": 1785763765.671819, "n": 71642, "h": "1bae952267cb15ab", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_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\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "901eccc88eeeab2b9b2b1667dc25d02d3ae3db1b": {"t": 1785763768.8079557, "n": 71477, "h": "c6f151a502a5061e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "732a6c45fa66ba38f93a5469724a4b0ee4a5d697": {"t": 1785763770.8713858, "n": 71229, "h": "47813f76cb94029b", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "5ead126709a7b22113f3949d4095391169c3f62c": {"t": 1785763773.607044, "n": 70582, "h": "b901d8604063d11e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if (\n _pname == \"Alibaba/DashScope\"\n and not _base\n and _resp.status_code == 401\n ):\n _resp = httpx.get(\n \"https://dashscope.aliyuncs.com/compatible-mode/v1/models\",\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "b62a82e0c3fbcdf219824c1512de180bae8a125c": {"t": 1785763777.3132617, "n": 70186, "h": "537716599ef16f15", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "eda326df160acf94c9aff362c86504391265b4ed": {"t": 1785763780.7388687, "n": 70169, "h": "9767ea4c4d2d59a7", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 providers/*.py 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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "20a4f79ed11da67318756d7a98141c0ebf56183f": {"t": 1785763783.1860054, "n": 69290, "h": "ea5e4f8593979d1c", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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 providers/*.py 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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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\", \"Vercel AI Gateway\": \"ai-gateway\",\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 try:\n from providers import list_providers\n from providers.base import ProviderProfile as _PP\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 # 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 _static.append((_label, _key_vars, _models_url, _base_var, True))\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\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n # Cached at module level after first build — profiles auto-extend it.\n global _APIKEY_PROVIDERS_CACHE\n if _APIKEY_PROVIDERS_CACHE is None:\n _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list()\n _apikey_providers = _APIKEY_PROVIDERS_CACHE\n for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "d8097d587f7e6cf6815ff3410e3a21bea87ebd6e": {"t": 1785763784.950293, "n": 66477, "h": "e82b48c9c1691c85", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\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_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "103f51ad34ee7817d3cd1cbf01144a66573333bb": {"t": 1785763786.6408684, "n": 66643, "h": "73f6fbbd1eeae3a0", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "05bec0ac79ad525997d569f17e47dcd39c401ed3": {"t": 1785763788.4242725, "n": 66334, "h": "173064a7ac5b0fd6", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\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 # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "055fde40e0470ead938678702135a066008f960b": {"t": 1785763790.2284262, "n": 66086, "h": "cfad54e5331295bf", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n elif shutil.which(\"agent-browser\"):\n check_ok(\"agent-browser\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "d2ea959fe9a86c0d3d90d552e35b191c67fcc8db": {"t": 1785763791.8999956, "n": 65980, "h": "f640d1ea1543fe80", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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 (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "c5e3a6fb5bb33477d639219de14922caedda98ef": {"t": 1785763793.740271, "n": 65942, "h": "54728ded40a9329e", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "f73364b1c4acffa3242d1c8272cba4f3f9a3b62e": {"t": 1785763795.9456728, "n": 65647, "h": "02764b798a22c883", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if _safe_which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \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 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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if _safe_which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if _safe_which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "828d3a320bbcd810969f13131573169f363049da": {"t": 1785763797.6147587, "n": 65449, "h": "cb43686a18152a44", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\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\n headers = {\"anthropic-version\": \"2023-06-01\"}\n is_oauth = _is_oauth_token(anthropic_key)\n if is_oauth:\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n # Reactive recovery: OAuth subscriptions that don't include 1M\n # context reject the request with 400 \"long context beta is not\n # yet available for this subscription\". Retry once with that\n # beta stripped so the doctor check doesn't falsely report the\n # Anthropic API as unreachable for those users.\n if (\n is_oauth\n and response.status_code == 400\n and \"long context beta\" in response.text.lower()\n and \"not yet available\" in response.text.lower()\n ):\n headers[\"anthropic-beta\"] = \",\".join(\n [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS)\n )\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10,\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "9eb16025bd91cd018b6d370f10b9b8ef5b7812a5": {"t": 1785763799.151705, "n": 64370, "h": "f2899f6d34c7f77c", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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\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 if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "13c238327eebfff862d9ed60593751e419989785": {"t": 1785763801.1184626, "n": 64021, "h": "b5ba91fe946b85f3", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES\n if runtime in _SUPPORTED_VERCEL_RUNTIMES:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n supported = \", \".join(_SUPPORTED_VERCEL_RUNTIMES)\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use {supported})\")\n issues.append(f\"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "5a1d4f68045bb46eb3fad28b001519e36c397444": {"t": 1785763803.4873495, "n": 63927, "h": "d148bd43c586fe27", "c": "\"\"\"\nDoctor command for hermes CLI.\n\nDiagnoses issues with Hermes Agent setup.\n\"\"\"\n\nimport os\nimport sys\nimport subprocess\nimport shutil\nimport importlib.util\nfrom pathlib import Path\n\nfrom hermes_cli.config import get_project_root, get_hermes_home, get_env_path\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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.models import _HERMES_USER_AGENT\nfrom hermes_cli.vercel_auth import describe_vercel_auth\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Vercel Sandbox (if using vercel_sandbox backend)\n if terminal_env == \"vercel_sandbox\":\n runtime = os.getenv(\"TERMINAL_VERCEL_RUNTIME\", \"node24\").strip() or \"node24\"\n if runtime in {\"node24\", \"node22\", \"python3.13\"}:\n check_ok(\"Vercel runtime\", f\"({runtime})\")\n else:\n check_fail(\"Vercel runtime unsupported\", f\"({runtime}; use node24, node22, or python3.13)\")\n issues.append(\"Set TERMINAL_VERCEL_RUNTIME to node24, node22, or python3.13\")\n\n disk = os.getenv(\"TERMINAL_CONTAINER_DISK\", \"51200\").strip()\n if disk in (\"\", \"0\", \"51200\"):\n check_ok(\"Vercel disk setting\", \"(uses platform default)\")\n else:\n check_fail(\"Vercel custom disk unsupported\", \"(reset terminal.container_disk to 51200)\")\n issues.append(\"Vercel Sandbox does not support custom container_disk; use the shared default 51200\")\n\n if importlib.util.find_spec(\"vercel\") is not None:\n check_ok(\"vercel SDK\", \"(installed)\")\n else:\n check_fail(\"vercel SDK not installed\", \"(pip install 'hermes-agent[vercel]')\")\n issues.append(\"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'\")\n\n auth_status = describe_vercel_auth()\n if auth_status.ok:\n check_ok(\"Vercel auth\", f\"({auth_status.label})\")\n elif auth_status.label.startswith(\"partial\"):\n check_fail(\"Vercel auth incomplete\", f\"({auth_status.label})\")\n issues.append(\"Set VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID together\")\n else:\n check_fail(\"Vercel auth not configured\", f\"({auth_status.label})\")\n issues.append(\n \"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID\"\n )\n for line in auth_status.detail_lines:\n check_info(f\"Vercel auth {line}\")\n\n persistent = os.getenv(\"TERMINAL_CONTAINER_PERSISTENT\", \"true\").lower() in (\"1\", \"true\", \"yes\", \"on\")\n if persistent:\n check_info(\"Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation\")\n else:\n check_info(\"Vercel persistence: ephemeral filesystem\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "fd943461cac026ab6e31d9531b1d57c8bad9962f": {"t": 1785763805.2402966, "n": 61594, "h": "7540428962f90f48", "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_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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\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 providers_accepting_vendor_slugs = {\n \"openrouter\",\n \"custom\",\n \"auto\",\n \"ai-gateway\",\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_for_policy\n and provider_for_policy not in providers_accepting_vendor_slugs\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if runtime_provider and runtime_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(runtime_provider)\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 check_fail(\n f\"model.provider '{runtime_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "214ca943ac743f09f8a7300c426b6106aeedcdad": {"t": 1785763807.4549053, "n": 59743, "h": "35f676fbcc380d8e", "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_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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 PROVIDER_REGISTRY\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import resolve_provider_full as _resolve_provider_full\n except Exception:\n _compatible_custom_providers = 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 canonical_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 canonical_provider = provider_def.id if provider_def is not None else None\n\n if provider and provider != \"auto\":\n if canonical_provider is None or (known_providers and canonical_provider not in known_providers):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them\n if default_model and \"/\" in default_model and canonical_provider and canonical_provider not in (\"openrouter\", \"custom\", \"auto\", \"ai-gateway\", \"kilocode\", \"opencode-zen\", \"huggingface\", \"nous\", \"lmstudio\"):\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if canonical_provider and canonical_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(canonical_provider)\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(canonical_provider) or {}\n configured = bool(status.get(\"configured\") or status.get(\"logged_in\") or status.get(\"api_key\"))\n if not configured:\n check_fail(\n f\"model.provider '{canonical_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\n f\"No credentials found for provider '{canonical_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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "06164a7b28cda2d651e34855b3c6718f1c6960e1": {"t": 1785763809.3955503, "n": 59731, "h": "90d2499cf6fd051b", "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_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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 PROVIDER_REGISTRY\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import resolve_provider_full as _resolve_provider_full\n except Exception:\n _compatible_custom_providers = 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 canonical_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 canonical_provider = provider_def.id if provider_def is not None else None\n\n if provider and provider != \"auto\":\n if canonical_provider is None or (known_providers and canonical_provider not in known_providers):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them\n if default_model and \"/\" in default_model and canonical_provider and canonical_provider not in (\"openrouter\", \"custom\", \"auto\", \"ai-gateway\", \"kilocode\", \"opencode-zen\", \"huggingface\", \"nous\"):\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if canonical_provider and canonical_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(canonical_provider)\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(canonical_provider) or {}\n configured = bool(status.get(\"configured\") or status.get(\"logged_in\") or status.get(\"api_key\"))\n if not configured:\n check_fail(\n f\"model.provider '{canonical_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\n f\"No credentials found for provider '{canonical_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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n # Native OAuth uses Hermes' own device-code flow — the Codex CLI is\n # only needed if you want to import existing tokens from\n # ~/.codex/auth.json. Downgrade to info so users running\n # `hermes auth openai-codex` aren't told they're missing something.\n check_info(\n \"codex CLI not installed \"\n \"(optional — only required to import tokens from an existing Codex CLI login)\"\n )\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "a6a6cf047d99ce27463960ffafbe3812612551dd": {"t": 1785763811.0969634, "n": 59367, "h": "aeae9d8994b3d1d2", "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_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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\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 \"AI_GATEWAY_API_KEY\",\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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 PROVIDER_REGISTRY\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import resolve_provider_full as _resolve_provider_full\n except Exception:\n _compatible_custom_providers = 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 canonical_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 canonical_provider = provider_def.id if provider_def is not None else None\n\n if provider and provider != \"auto\":\n if canonical_provider is None or (known_providers and canonical_provider not in known_providers):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them\n if default_model and \"/\" in default_model and canonical_provider and canonical_provider not in (\"openrouter\", \"custom\", \"auto\", \"ai-gateway\", \"kilocode\", \"opencode-zen\", \"huggingface\", \"nous\"):\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if canonical_provider and canonical_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(canonical_provider)\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(canonical_provider) or {}\n configured = bool(status.get(\"configured\") or status.get(\"logged_in\") or status.get(\"api_key\"))\n if not configured:\n check_fail(\n f\"model.provider '{canonical_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\n f\"No credentials found for provider '{canonical_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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n check_warn(\"codex CLI not found\", \"(required for openai-codex login)\")\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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"}, "c53fcb01731587480a08b7e3ca28e04a0652f223": {"t": 1785763812.9405992, "n": 59343, "h": "801a21d80629a642", "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_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\nfrom dotenv import load_dotenv\n_env_path = get_env_path()\nif _env_path.exists():\n try:\n load_dotenv(_env_path, encoding=\"utf-8\")\n except UnicodeDecodeError:\n load_dotenv(_env_path, encoding=\"latin-1\")\n# Also try project .env as dev fallback\nload_dotenv(PROJECT_ROOT / \".env\", override=False, encoding=\"utf-8\")\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 \"AI_GATEWAY_API_KEY\",\n \"OPENCODE_ZEN_API_KEY\",\n \"OPENCODE_GO_API_KEY\",\n \"XIAOMI_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 _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 _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 _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 if not _honcho_is_configured_for_doctor():\n return available, unavailable\n\n updated_available = list(available)\n updated_unavailable = []\n for item in unavailable:\n if item.get(\"name\") == \"honcho\":\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 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 _check_gateway_service_linger(issues: list[str]) -> None:\n \"\"\"Warn when a systemd user gateway service will stop after logout.\"\"\"\n try:\n from hermes_cli.gateway import (\n get_systemd_linger_status,\n get_systemd_unit_path,\n is_linux,\n )\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 unit_path = get_systemd_unit_path()\n if not unit_path.exists():\n return\n\n print()\n print(color(\"◆ Gateway Service\", Colors.CYAN, Colors.BOLD))\n\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\ndef run_doctor(args):\n \"\"\"Run diagnostic checks.\"\"\"\n should_fix = getattr(args, 'fix', False)\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 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 # =========================================================================\n # Check: Python version\n # =========================================================================\n print()\n print(color(\"◆ Python Environment\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(f\"Python {py_version.major}.{py_version.minor}.{py_version.micro}\", \"(3.10+ required)\")\n issues.append(\"Upgrade Python to 3.10+\")\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 # =========================================================================\n # Check: Required packages\n # =========================================================================\n print()\n print(color(\"◆ Required Packages\", Colors.CYAN, Colors.BOLD))\n \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 check_fail(name, \"(missing)\")\n issues.append(f\"Install {name}: {_python_install_cmd()} {module}\")\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 # =========================================================================\n # Check: Configuration files\n # =========================================================================\n print()\n print(color(\"◆ Configuration Files\", Colors.CYAN, Colors.BOLD))\n \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\n content = env_path.read_text()\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 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 PROVIDER_REGISTRY\n known_providers = set(PROVIDER_REGISTRY.keys()) | {\"openrouter\", \"custom\", \"auto\"}\n except Exception:\n pass\n try:\n from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers\n from hermes_cli.providers import resolve_provider_full as _resolve_provider_full\n except Exception:\n _compatible_custom_providers = 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 canonical_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 canonical_provider = provider_def.id if provider_def is not None else None\n\n if provider and provider != \"auto\":\n if canonical_provider is None or (known_providers and canonical_provider not in known_providers):\n known_list = \", \".join(sorted(known_providers)) if known_providers else \"(unavailable)\"\n check_fail(\n f\"model.provider '{provider_raw}' is not a recognised provider\",\n f\"(known: {known_list})\",\n )\n issues.append(\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\n # Warn if model is set to a provider-prefixed name on a provider that doesn't use them\n if default_model and \"/\" in default_model and canonical_provider and canonical_provider not in (\"openrouter\", \"custom\", \"auto\", \"ai-gateway\", \"kilocode\", \"opencode-zen\", \"huggingface\", \"nous\"):\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, openrouter/anthropic/custom/auto) have their\n # own env-var checks elsewhere in doctor, and get_auth_status()\n # returns a bare {logged_in: False} for anything it doesn't\n # explicitly dispatch, which would produce false positives.\n if canonical_provider and canonical_provider not in (\"auto\", \"custom\", \"openrouter\"):\n try:\n from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status\n pconfig = PROVIDER_REGISTRY.get(canonical_provider)\n if pconfig and getattr(pconfig, \"auth_type\", \"\") == \"api_key\":\n status = get_auth_status(canonical_provider) or {}\n configured = bool(status.get(\"configured\") or status.get(\"logged_in\") or status.get(\"api_key\"))\n if not configured:\n check_fail(\n f\"model.provider '{canonical_provider}' is set but no API key is configured\",\n \"(check ~/.hermes/.env or run 'hermes setup')\",\n )\n issues.append(\n f\"No credentials found for provider '{canonical_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 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 example_config = PROJECT_ROOT / 'cli-config.yaml.example'\n if should_fix and example_config.exists():\n config_path.parent.mkdir(parents=True, exist_ok=True)\n shutil.copy2(str(example_config), str(config_path))\n check_ok(f\"Created {_DHH}/config.yaml from cli-config.yaml.example\")\n fixed_count += 1\n elif should_fix:\n check_warn(\"config.yaml not found and no example to copy from\")\n manual_issues.append(f\"Create {_DHH}/config.yaml manually\")\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) 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 model_section = raw_config.setdefault(\"model\", {})\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 # 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 print()\n print(color(\"◆ Config Structure\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Check: Auth providers\n # =========================================================================\n print()\n print(color(\"◆ Auth Providers\", Colors.CYAN, Colors.BOLD))\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 )\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\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 except Exception as e:\n check_warn(\"Auth provider status\", f\"(could not check: {e})\")\n\n if shutil.which(\"codex\"):\n check_ok(\"codex CLI\")\n else:\n check_warn(\"codex CLI not found\", \"(required for openai-codex login)\")\n\n # =========================================================================\n # Check: Directory structure\n # =========================================================================\n print()\n print(color(\"◆ Directory Structure\", Colors.CYAN, Colors.BOLD))\n \n hermes_home = HERMES_HOME\n if hermes_home.exists():\n check_ok(f\"{_DHH} directory exists\")\n else:\n if 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 else:\n if 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\n # =========================================================================\n # Check: Command installation (hermes bin symlink)\n # =========================================================================\n if sys.platform != \"win32\":\n print()\n print(color(\"◆ Command Installation\", Colors.CYAN, Colors.BOLD))\n\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 # =========================================================================\n # Check: External tools\n # =========================================================================\n print()\n print(color(\"◆ External Tools\", Colors.CYAN, Colors.BOLD))\n \n # Git\n if shutil.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 shutil.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 if terminal_env == \"docker\":\n if shutil.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 check_fail(\"docker daemon not running\")\n issues.append(\"Start Docker daemon\")\n else:\n check_fail(\"docker not found\", \"(required for TERMINAL_ENV=docker)\")\n issues.append(\"Install Docker or change TERMINAL_ENV\")\n else:\n if shutil.which(\"docker\"):\n check_ok(\"docker\", \"(optional)\")\n else:\n if _is_termux():\n check_info(\"Docker backend is not available inside Termux (expected on Android)\")\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 # Try to connect\n try:\n result = subprocess.run(\n [\"ssh\", \"-o\", \"ConnectTimeout=5\", \"-o\", \"BatchMode=yes\", ssh_host, \"echo ok\"],\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 check_fail(f\"SSH connection to {ssh_host}\")\n issues.append(f\"Check SSH configuration for {ssh_host}\")\n else:\n check_fail(\"TERMINAL_SSH_HOST not set\", \"(required for TERMINAL_ENV=ssh)\")\n issues.append(\"Set TERMINAL_SSH_HOST in .env\")\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 check_fail(\"DAYTONA_API_KEY not set\", \"(required for TERMINAL_ENV=daytona)\")\n issues.append(\"Set DAYTONA_API_KEY environment variable\")\n try:\n from daytona import Daytona # noqa: F401 — SDK presence check\n check_ok(\"daytona SDK\", \"(installed)\")\n except ImportError:\n check_fail(\"daytona SDK not installed\", \"(pip install daytona)\")\n issues.append(\"Install daytona SDK: pip install daytona\")\n\n # Node.js + agent-browser (for browser automation tools)\n if shutil.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 if agent_browser_path.exists():\n check_ok(\"agent-browser (Node.js)\", \"(browser automation)\")\n else:\n if _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 else:\n if _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 if shutil.which(\"npm\"):\n npm_dirs = [\n (PROJECT_ROOT, \"Browser tools (agent-browser)\"),\n (PROJECT_ROOT / \"scripts\" / \"whatsapp-bridge\", \"WhatsApp bridge\"),\n ]\n for npm_dir, label in npm_dirs:\n if not (npm_dir / \"node_modules\").exists():\n continue\n try:\n audit_result = subprocess.run(\n [\"npm\", \"audit\", \"--json\"],\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 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: cd {npm_dir} && npm audit fix)\"\n )\n issues.append(f\"{label} has {total} npm vulnerability(ies)\")\n else:\n check_ok(f\"{label} deps\", f\"({moderate} moderate vulnerability(ies))\")\n except Exception:\n pass\n\n # =========================================================================\n # Check: API connectivity\n # =========================================================================\n print()\n print(color(\"◆ API Connectivity\", Colors.CYAN, Colors.BOLD))\n \n openrouter_key = os.getenv(\"OPENROUTER_API_KEY\")\n if openrouter_key:\n print(\" Checking OpenRouter API...\", end=\"\", flush=True)\n try:\n import httpx\n response = httpx.get(\n OPENROUTER_MODELS_URL,\n headers={\"Authorization\": f\"Bearer {openrouter_key}\"},\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} OpenRouter API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(invalid API key)', Colors.DIM)} \")\n issues.append(\"Check OPENROUTER_API_KEY in .env\")\n elif response.status_code == 402:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(out of credits — payment required)', Colors.DIM)}\")\n issues.append(\n \"OpenRouter account has insufficient credits. \"\n \"Fix: run 'hermes config set model.provider ' to switch providers, \"\n \"or fund your OpenRouter account at https://openrouter.ai/settings/credits\"\n )\n elif response.status_code == 429:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color('(rate limited)', Colors.DIM)} \")\n issues.append(\"OpenRouter rate limit hit — consider switching to a different provider or waiting\")\n else:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'(HTTP {response.status_code})', Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('✗', Colors.RED)} OpenRouter API {color(f'({e})', Colors.DIM)} \")\n issues.append(\"Check network connectivity\")\n else:\n check_warn(\"OpenRouter API\", \"(not configured)\")\n \n from hermes_cli.auth import get_anthropic_key\n anthropic_key = get_anthropic_key()\n if anthropic_key:\n print(\" Checking Anthropic API...\", end=\"\", flush=True)\n try:\n import httpx\n from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS\n\n headers = {\"anthropic-version\": \"2023-06-01\"}\n if _is_oauth_token(anthropic_key):\n headers[\"Authorization\"] = f\"Bearer {anthropic_key}\"\n headers[\"anthropic-beta\"] = \",\".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS)\n else:\n headers[\"x-api-key\"] = anthropic_key\n response = httpx.get(\n \"https://api.anthropic.com/v1/models\",\n headers=headers,\n timeout=10\n )\n if response.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} Anthropic API \")\n elif response.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} Anthropic API {color('(invalid API key)', Colors.DIM)} \")\n else:\n msg = \"(couldn't verify)\"\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(msg, Colors.DIM)} \")\n except Exception as e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} Anthropic API {color(f'({e})', Colors.DIM)} \")\n\n # -- API-key providers --\n # Tuple: (name, env_vars, default_url, base_env, supports_models_endpoint)\n # If supports_models_endpoint is False, we skip the health check and just show \"configured\"\n _apikey_providers = [\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: the /anthropic endpoint doesn't support /models, but the /v1 endpoint does.\n (\"MiniMax\", (\"MINIMAX_API_KEY\",), \"https://api.minimax.io/v1/models\", \"MINIMAX_BASE_URL\", True),\n (\"MiniMax (China)\", (\"MINIMAX_CN_API_KEY\",), \"https://api.minimaxi.com/v1/models\", \"MINIMAX_CN_BASE_URL\", True),\n (\"Vercel AI Gateway\", (\"AI_GATEWAY_API_KEY\",), \"https://ai-gateway.vercel.sh/v1/models\", \"AI_GATEWAY_BASE_URL\", True),\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 for _pname, _env_vars, _default_url, _base_env, _supports_health_check in _apikey_providers:\n _key = \"\"\n for _ev in _env_vars:\n _key = os.getenv(_ev, \"\")\n if _key:\n break\n if _key:\n _label = _pname.ljust(20)\n # Some providers (like MiniMax) don't support /models endpoint\n if not _supports_health_check:\n print(f\" {color('✓', Colors.GREEN)} {_label} {color('(key configured)', Colors.DIM)}\")\n continue\n print(f\" Checking {_pname} API...\", end=\"\", flush=True)\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 the 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 _resp = httpx.get(\n _url,\n headers=_headers,\n timeout=10,\n )\n if _resp.status_code == 200:\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} \")\n elif _resp.status_code == 401:\n print(f\"\\r {color('✗', Colors.RED)} {_label} {color('(invalid API key)', Colors.DIM)} \")\n issues.append(f\"Check {_env_vars[0]} in .env\")\n else:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(HTTP {_resp.status_code})', Colors.DIM)} \")\n except Exception as _e:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_e})', Colors.DIM)} \")\n\n # -- AWS Bedrock --\n # Bedrock uses the AWS SDK credential chain, not API keys.\n try:\n from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region\n if has_aws_credentials():\n _auth_var = resolve_aws_auth_env_var()\n _region = resolve_bedrock_region()\n _label = \"AWS Bedrock\".ljust(20)\n print(f\" Checking AWS Bedrock...\", end=\"\", flush=True)\n try:\n import boto3\n _br_client = boto3.client(\"bedrock\", region_name=_region)\n _br_resp = _br_client.list_foundation_models()\n _model_count = len(_br_resp.get(\"modelSummaries\", []))\n print(f\"\\r {color('✓', Colors.GREEN)} {_label} {color(f'({_auth_var}, {_region}, {_model_count} models)', Colors.DIM)} \")\n except ImportError:\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'(boto3 not installed — {sys.executable} -m pip install boto3)', Colors.DIM)} \")\n issues.append(f\"Install boto3 for Bedrock: {sys.executable} -m pip install boto3\")\n except Exception as _e:\n _err_name = type(_e).__name__\n print(f\"\\r {color('⚠', Colors.YELLOW)} {_label} {color(f'({_err_name}: {_e})', Colors.DIM)} \")\n issues.append(f\"AWS Bedrock: {_err_name} — check IAM permissions for bedrock:ListFoundationModels\")\n except ImportError:\n pass # bedrock_adapter not available — skip silently\n\n # =========================================================================\n # Check: Submodules\n # =========================================================================\n print()\n print(color(\"◆ Submodules\", Colors.CYAN, Colors.BOLD))\n \n # tinker-atropos (RL training backend)\n tinker_dir = PROJECT_ROOT / \"tinker-atropos\"\n if tinker_dir.exists() and (tinker_dir / \"pyproject.toml\").exists():\n if py_version >= (3, 11):\n try:\n __import__(\"tinker_atropos\")\n check_ok(\"tinker-atropos\", \"(RL training backend)\")\n except ImportError:\n install_cmd = f\"{_python_install_cmd()} -e ./tinker-atropos\"\n check_warn(\"tinker-atropos found but not installed\", f\"(run: {install_cmd})\")\n issues.append(f\"Install tinker-atropos: {install_cmd}\")\n else:\n check_warn(\"tinker-atropos requires Python 3.11+\", f\"(current: {py_version.major}.{py_version.minor})\")\n else:\n check_warn(\"tinker-atropos not found\", \"(run: git submodule update --init --recursive)\")\n \n # =========================================================================\n # Check: Tool Availability\n # =========================================================================\n print()\n print(color(\"◆ Tool Availability\", Colors.CYAN, Colors.BOLD))\n \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))\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 # =========================================================================\n # Check: Skills Hub\n # =========================================================================\n print()\n print(color(\"◆ Skills Hub\", Colors.CYAN, Colors.BOLD))\n\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 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 else:\n check_warn(\"No GITHUB_TOKEN\", f\"(60 req/hr rate limit — set in {_DHH}/.env for better rates)\")\n\n # =========================================================================\n # Memory Provider (only check the active provider, if any)\n # =========================================================================\n print()\n print(color(\"◆ Memory Provider\", Colors.CYAN, Colors.BOLD))\n\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) 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 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 check_fail(\"Honcho API key or base URL not set\", \"run: hermes memory setup\")\n issues.append(\"No Honcho API key — run 'hermes memory setup'\")\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 check_fail(\"Honcho connection failed\", str(_e))\n issues.append(f\"Honcho unreachable: {_e}\")\n except ImportError:\n check_fail(\"honcho-ai not installed\", \"pip install honcho-ai\")\n issues.append(\"Honcho is set as memory provider but honcho-ai is not installed\")\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 check_fail(\"Mem0 API key not set\", \"(set MEM0_API_KEY in .env or run hermes memory setup)\")\n issues.append(\"Mem0 is set as memory provider but API key is missing\")\n except ImportError:\n check_fail(\"Mem0 plugin not loadable\", \"pip install mem0ai\")\n issues.append(\"Mem0 is set as memory provider but mem0ai is not installed\")\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 # =========================================================================\n # Profiles\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 print()\n print(color(\"◆ Profiles\", Colors.CYAN, Colors.BOLD))\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 # =========================================================================\n # Summary\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/config.py": {"608a2936e1b87d0201c104e211c08a4b99c359fb": {"t": 1785746885.720954, "n": 219848, "h": "a275f9383e34b04f", "c": "\"\"\"\nConfiguration management for Hermes Agent.\n\nConfig files are stored in ~/.hermes/ for easy access:\n- ~/.hermes/config.yaml - All settings (model, toolsets, terminal, etc.)\n- ~/.hermes/.env - API keys and secrets\n\nThis module provides:\n- hermes config - Show current configuration\n- hermes config edit - Open config in editor\n- hermes config set - Set a specific value\n- hermes config wizard - Re-run setup wizard\n\"\"\"\n\nimport copy\nimport logging\nimport os\nimport platform\nimport re\nimport stat\nimport subprocess\nimport sys\nimport tempfile\nimport threading\nfrom dataclasses import dataclass\nfrom pathlib import Path\nfrom typing import Dict, Any, Optional, List, Tuple\n\nlogger = logging.getLogger(__name__)\n\n# Track which (config_path, mtime_ns, size) tuples we've already warned about\n# so concurrent CLI/gateway loads of a broken config.yaml don't spam stderr\n# every time. Cleared automatically when the file changes (different mtime).\n_CONFIG_PARSE_WARNED: set = set()\n\n\ndef _warn_config_parse_failure(config_path: Path, exc: Exception) -> None:\n \"\"\"Surface a config.yaml parse failure to user, log, and stderr.\n\n A YAML parse error in ``~/.hermes/config.yaml`` causes ``load_config()``\n to silently fall back to ``DEFAULT_CONFIG``, which means every user\n override (auxiliary providers, fallback chain, model overrides, etc.)\n is dropped. Before this helper that was a one-line ``print(...)`` that\n scrolled off-screen on the first invocation and was never seen again.\n\n Now: warn once per (path, mtime_ns, size) on stderr **and** in\n ``agent.log`` / ``errors.log`` at WARNING level so ``hermes logs``\n surfaces it. Re-warns automatically if the file changes (different\n mtime/size), so users editing the config see the next failure.\n \"\"\"\n try:\n st = config_path.stat()\n key = (str(config_path), st.st_mtime_ns, st.st_size)\n except OSError:\n key = (str(config_path), 0, 0)\n if key in _CONFIG_PARSE_WARNED:\n return\n _CONFIG_PARSE_WARNED.add(key)\n\n msg = (\n f\"Failed to parse {config_path}: {exc}. \"\n f\"Falling back to default config — every user override \"\n f\"(auxiliary providers, fallback chain, model settings) is being IGNORED. \"\n f\"Fix the YAML and restart.\"\n )\n logger.warning(msg)\n try:\n sys.stderr.write(f\"⚠️ hermes config: {msg}\\n\")\n sys.stderr.flush()\n except Exception:\n pass\n\n_IS_WINDOWS = platform.system() == \"Windows\"\n_ENV_VAR_NAME_RE = re.compile(r\"^[A-Za-z_][A-Za-z0-9_]*$\")\n_LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {}\n# (path, mtime_ns, size) -> cached expanded config dict.\n# load_config() returns a deepcopy of the cached value when the file\n# hasn't changed since the last load, skipping yaml.safe_load +\n# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call).\n# save_config() + migrate_config() write via atomic_yaml_write which\n# produces a fresh inode, so stat() sees a new mtime_ns and the next\n# load repopulates automatically — no explicit invalidation hook.\n_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as\n# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want\n# the user's on-disk values without defaults merged in.\n_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {}\n# Serializes all config read/write paths. libyaml's C extension is not\n# thread-safe for concurrent safe_load() on the same file, and multiple\n# tool threads (approval.py, browser_tool.py, setup flows) hit\n# load_config / read_raw_config / save_config from different threads\n# during long agent runs. RLock (not Lock) because save_config internally\n# calls read_raw_config. Also covers mutation of the module-level cache\n# dicts above.\n_CONFIG_LOCK = threading.RLock()\n# Env var names written to .env that aren't in OPTIONAL_ENV_VARS\n# (managed by setup/provider flows directly).\n_EXTRA_ENV_KEYS = frozenset({\n \"OPENAI_API_KEY\", \"OPENAI_BASE_URL\",\n \"ANTHROPIC_API_KEY\", \"ANTHROPIC_TOKEN\",\n \"DISCORD_HOME_CHANNEL\", \"DISCORD_HOME_CHANNEL_NAME\",\n \"TELEGRAM_HOME_CHANNEL\", \"TELEGRAM_HOME_CHANNEL_NAME\",\n \"SLACK_HOME_CHANNEL\", \"SLACK_HOME_CHANNEL_NAME\",\n \"SIGNAL_ACCOUNT\", \"SIGNAL_HTTP_URL\",\n \"SIGNAL_ALLOWED_USERS\", \"SIGNAL_GROUP_ALLOWED_USERS\",\n \"SIGNAL_HOME_CHANNEL\", \"SIGNAL_HOME_CHANNEL_NAME\",\n \"SMS_HOME_CHANNEL\", \"SMS_HOME_CHANNEL_NAME\",\n \"DINGTALK_CLIENT_ID\", \"DINGTALK_CLIENT_SECRET\",\n \"DINGTALK_HOME_CHANNEL\", \"DINGTALK_HOME_CHANNEL_NAME\",\n \"FEISHU_APP_ID\", \"FEISHU_APP_SECRET\", \"FEISHU_ENCRYPT_KEY\", \"FEISHU_VERIFICATION_TOKEN\",\n \"FEISHU_HOME_CHANNEL\", \"FEISHU_HOME_CHANNEL_NAME\",\n \"YUANBAO_HOME_CHANNEL\", \"YUANBAO_HOME_CHANNEL_NAME\",\n \"WECOM_BOT_ID\", \"WECOM_SECRET\",\n \"WECOM_CALLBACK_CORP_ID\", \"WECOM_CALLBACK_CORP_SECRET\", \"WECOM_CALLBACK_AGENT_ID\",\n \"WECOM_CALLBACK_TOKEN\", \"WECOM_CALLBACK_ENCODING_AES_KEY\",\n \"WECOM_CALLBACK_HOST\", \"WECOM_CALLBACK_PORT\",\n \"WECOM_HOME_CHANNEL\", \"WECOM_HOME_CHANNEL_NAME\",\n \"WEIXIN_ACCOUNT_ID\", \"WEIXIN_TOKEN\", \"WEIXIN_BASE_URL\", \"WEIXIN_CDN_BASE_URL\",\n \"WEIXIN_HOME_CHANNEL\", \"WEIXIN_HOME_CHANNEL_NAME\", \"WEIXIN_DM_POLICY\", \"WEIXIN_GROUP_POLICY\",\n \"WEIXIN_ALLOWED_USERS\", \"WEIXIN_GROUP_ALLOWED_USERS\", \"WEIXIN_ALLOW_ALL_USERS\",\n \"BLUEBUBBLES_SERVER_URL\", \"BLUEBUBBLES_PASSWORD\",\n \"BLUEBUBBLES_HOME_CHANNEL\", \"BLUEBUBBLES_HOME_CHANNEL_NAME\",\n \"QQ_APP_ID\", \"QQ_CLIENT_SECRET\", \"QQBOT_HOME_CHANNEL\", \"QQBOT_HOME_CHANNEL_NAME\",\n \"QQ_HOME_CHANNEL\", \"QQ_HOME_CHANNEL_NAME\", # legacy aliases (pre-rename, still read for back-compat)\n \"QQ_ALLOWED_USERS\", \"QQ_GROUP_ALLOWED_USERS\", \"QQ_ALLOW_ALL_USERS\", \"QQ_MARKDOWN_SUPPORT\",\n \"QQ_STT_API_KEY\", \"QQ_STT_BASE_URL\", \"QQ_STT_MODEL\",\n \"IRC_SERVER\", \"IRC_PORT\", \"IRC_NICKNAME\", \"IRC_CHANNEL\",\n \"IRC_USE_TLS\", \"IRC_SERVER_PASSWORD\", \"IRC_NICKSERV_PASSWORD\",\n \"TERMINAL_ENV\", \"TERMINAL_SSH_KEY\", \"TERMINAL_SSH_PORT\",\n \"WHATSAPP_MODE\", \"WHATSAPP_ENABLED\",\n \"MATTERMOST_HOME_CHANNEL\", \"MATTERMOST_HOME_CHANNEL_NAME\", \"MATTERMOST_REPLY_MODE\",\n \"MATRIX_PASSWORD\", \"MATRIX_ENCRYPTION\", \"MATRIX_DEVICE_ID\", \"MATRIX_HOME_ROOM\",\n \"MATRIX_REQUIRE_MENTION\", \"MATRIX_FREE_RESPONSE_ROOMS\", \"MATRIX_AUTO_THREAD\", \"MATRIX_DM_AUTO_THREAD\",\n \"MATRIX_RECOVERY_KEY\",\n # Langfuse observability plugin — optional tuning keys + standard SDK vars.\n # Activation is via plugins.enabled (opt-in through `hermes plugins enable\n # observability/langfuse` or `hermes tools → Langfuse`); credentials gate\n # the plugin at runtime.\n \"HERMES_LANGFUSE_ENV\",\n \"HERMES_LANGFUSE_RELEASE\",\n \"HERMES_LANGFUSE_SAMPLE_RATE\",\n \"HERMES_LANGFUSE_MAX_CHARS\",\n \"HERMES_LANGFUSE_DEBUG\",\n \"LANGFUSE_PUBLIC_KEY\",\n \"LANGFUSE_SECRET_KEY\",\n \"LANGFUSE_BASE_URL\",\n})\nimport yaml\n\nfrom hermes_cli.colors import Colors, color\nfrom hermes_cli.default_soul import DEFAULT_SOUL_MD\n\n\n# =============================================================================\n# Managed mode (NixOS declarative config)\n# =============================================================================\n\n_MANAGED_TRUE_VALUES = (\"true\", \"1\", \"yes\")\n_MANAGED_SYSTEM_NAMES = {\n \"brew\": \"Homebrew\",\n \"homebrew\": \"Homebrew\",\n \"nix\": \"NixOS\",\n \"nixos\": \"NixOS\",\n}\n\n\ndef get_managed_system() -> Optional[str]:\n \"\"\"Return the package manager owning this install, if any.\"\"\"\n raw = os.getenv(\"HERMES_MANAGED\", \"\").strip()\n if raw:\n normalized = raw.lower()\n if normalized in _MANAGED_TRUE_VALUES:\n return \"NixOS\"\n return _MANAGED_SYSTEM_NAMES.get(normalized, raw)\n\n managed_marker = get_hermes_home() / \".managed\"\n if managed_marker.exists():\n return \"NixOS\"\n return None\n\n\ndef is_managed() -> bool:\n \"\"\"Check if Hermes is running in package-manager-managed mode.\n\n Two signals: the HERMES_MANAGED env var (set by the systemd service),\n or a .managed marker file in HERMES_HOME (set by the NixOS activation\n script, so interactive shells also see it).\n \"\"\"\n return get_managed_system() is not None\n\n\ndef get_managed_update_command() -> Optional[str]:\n \"\"\"Return the preferred upgrade command for a managed install.\"\"\"\n managed_system = get_managed_system()\n if managed_system == \"Homebrew\":\n return \"brew upgrade hermes-agent\"\n if managed_system == \"NixOS\":\n return \"sudo nixos-rebuild switch\"\n return None\n\n\ndef recommended_update_command() -> str:\n \"\"\"Return the best update command for the current installation.\"\"\"\n return get_managed_update_command() or \"hermes update\"\n\n\ndef format_managed_message(action: str = \"modify this Hermes installation\") -> str:\n \"\"\"Build a user-facing error for managed installs.\"\"\"\n managed_system = get_managed_system() or \"a package manager\"\n raw = os.getenv(\"HERMES_MANAGED\", \"\").strip().lower()\n\n if managed_system == \"NixOS\":\n env_hint = \"true\" if raw in _MANAGED_TRUE_VALUES else raw or \"true\"\n return (\n f\"Cannot {action}: this Hermes installation is managed by NixOS \"\n f\"(HERMES_MANAGED={env_hint}).\\n\"\n \"Edit services.hermes-agent.settings in your configuration.nix and run:\\n\"\n \" sudo nixos-rebuild switch\"\n )\n\n if managed_system == \"Homebrew\":\n env_hint = raw or \"homebrew\"\n return (\n f\"Cannot {action}: this Hermes installation is managed by Homebrew \"\n f\"(HERMES_MANAGED={env_hint}).\\n\"\n \"Use:\\n\"\n \" brew upgrade hermes-agent\"\n )\n\n return (\n f\"Cannot {action}: this Hermes installation is managed by {managed_system}.\\n\"\n \"Use your package manager to upgrade or reinstall Hermes.\"\n )\n\ndef managed_error(action: str = \"modify configuration\"):\n \"\"\"Print user-friendly error for managed mode.\"\"\"\n print(format_managed_message(action), file=sys.stderr)\n\n\n# =============================================================================\n# Container-aware CLI (NixOS container mode)\n# =============================================================================\n\ndef get_container_exec_info() -> Optional[dict]:\n \"\"\"Read container mode metadata from HERMES_HOME/.container-mode.\n\n Returns a dict with keys: backend, container_name, exec_user, hermes_bin\n or None if container mode is not active, we're already inside the\n container, or HERMES_DEV=1 is set.\n\n The .container-mode file is written by the NixOS activation script when\n container.enable = true. It tells the host CLI to exec into the container\n instead of running locally.\n \"\"\"\n if os.environ.get(\"HERMES_DEV\") == \"1\":\n return None\n\n from hermes_constants import is_container\n if is_container():\n return None\n\n container_mode_file = get_hermes_home() / \".container-mode\"\n\n try:\n info = {}\n with open(container_mode_file, \"r\", encoding=\"utf-8\") as f:\n for line in f:\n line = line.strip()\n if \"=\" in line and not line.startswith(\"#\"):\n key, _, value = line.partition(\"=\")\n info[key.strip()] = value.strip()\n except FileNotFoundError:\n return None\n # All other exceptions (PermissionError, malformed data, etc.) propagate\n\n backend = info.get(\"backend\", \"docker\")\n container_name = info.get(\"container_name\", \"hermes-agent\")\n exec_user = info.get(\"exec_user\", \"hermes\")\n hermes_bin = info.get(\"hermes_bin\", \"/data/current-package/bin/hermes\")\n\n return {\n \"backend\": backend,\n \"container_name\": container_name,\n \"exec_user\": exec_user,\n \"hermes_bin\": hermes_bin,\n }\n\n\n# =============================================================================\n# Config paths\n# =============================================================================\n\n# Re-export from hermes_constants — canonical definition lives there.\nfrom hermes_constants import get_hermes_home # noqa: F811,E402\nfrom utils import atomic_replace\n\ndef get_config_path() -> Path:\n \"\"\"Get the main config file path.\"\"\"\n return get_hermes_home() / \"config.yaml\"\n\ndef get_env_path() -> Path:\n \"\"\"Get the .env file path (for API keys).\"\"\"\n return get_hermes_home() / \".env\"\n\ndef get_project_root() -> Path:\n \"\"\"Get the project installation directory.\"\"\"\n return Path(__file__).parent.parent.resolve()\n\ndef _secure_dir(path):\n \"\"\"Set directory to owner-only access (0700 by default). No-op on Windows.\n\n Skipped in managed mode — the NixOS module sets group-readable\n permissions (0750) so interactive users in the hermes group can\n share state with the gateway service.\n\n The mode can be overridden via the HERMES_HOME_MODE environment variable\n (e.g. HERMES_HOME_MODE=0701) for deployments where a web server (nginx,\n caddy, etc.) needs to traverse HERMES_HOME to reach a served subdirectory.\n The execute-only bit on a directory permits cd-through without exposing\n directory listings.\n \"\"\"\n if is_managed():\n return\n try:\n mode_str = os.environ.get(\"HERMES_HOME_MODE\", \"\").strip()\n mode = int(mode_str, 8) if mode_str else 0o700\n except ValueError:\n mode = 0o700\n try:\n os.chmod(path, mode)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _is_container() -> bool:\n \"\"\"Detect if we're running inside a Docker/Podman/LXC container.\n\n When Hermes runs in a container with volume-mounted config files, forcing\n 0o600 permissions breaks multi-process setups where the gateway and\n dashboard run as different UIDs or the volume mount requires broader\n permissions.\n \"\"\"\n # Explicit opt-out\n if os.environ.get(\"HERMES_CONTAINER\") or os.environ.get(\"HERMES_SKIP_CHMOD\"):\n return True\n # Docker / Podman marker file\n if os.path.exists(\"/.dockerenv\"):\n return True\n # LXC / cgroup-based detection\n try:\n with open(\"/proc/1/cgroup\", \"r\", encoding=\"utf-8\") as f:\n cgroup_content = f.read()\n if \"docker\" in cgroup_content or \"lxc\" in cgroup_content or \"kubepods\" in cgroup_content:\n return True\n except (OSError, IOError):\n pass\n return False\n\n\ndef _secure_file(path):\n \"\"\"Set file to owner-only read/write (0600). No-op on Windows.\n\n Skipped in managed mode — the NixOS activation script sets\n group-readable permissions (0640) on config files.\n\n Skipped in containers — Docker/Podman volume mounts often need broader\n permissions. Set HERMES_SKIP_CHMOD=1 to force-skip on other systems.\n \"\"\"\n if is_managed() or _is_container():\n return\n try:\n if os.path.exists(str(path)):\n os.chmod(path, 0o600)\n except (OSError, NotImplementedError):\n pass\n\n\ndef _ensure_default_soul_md(home: Path) -> None:\n \"\"\"Seed a default SOUL.md into HERMES_HOME if the user doesn't have one yet.\"\"\"\n soul_path = home / \"SOUL.md\"\n if soul_path.exists():\n return\n soul_path.write_text(DEFAULT_SOUL_MD, encoding=\"utf-8\")\n _secure_file(soul_path)\n\n\ndef ensure_hermes_home():\n \"\"\"Ensure ~/.hermes directory structure exists with secure permissions.\n\n In managed mode (NixOS), dirs are created by the activation script with\n setgid + group-writable (2770). We skip mkdir and set umask(0o007) so\n any files created (e.g. SOUL.md) are group-writable (0660).\n \"\"\"\n home = get_hermes_home()\n if is_managed():\n old_umask = os.umask(0o007)\n try:\n _ensure_hermes_home_managed(home)\n finally:\n os.umask(old_umask)\n else:\n home.mkdir(parents=True, exist_ok=True)\n _secure_dir(home)\n for subdir in (\"cron\", \"sessions\", \"logs\", \"logs/curator\", \"memories\"):\n d = home / subdir\n d.mkdir(parents=True, exist_ok=True)\n _secure_dir(d)\n _ensure_default_soul_md(home)\n\n\ndef _ensure_hermes_home_managed(home: Path):\n \"\"\"Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.\"\"\"\n if not home.is_dir():\n raise RuntimeError(\n f\"HERMES_HOME {home} does not exist. \"\n \"Run 'sudo nixos-rebuild switch' first.\"\n )\n for subdir in (\"cron\", \"sessions\", \"logs\", \"memories\"):\n d = home / subdir\n if not d.is_dir():\n raise RuntimeError(\n f\"{d} does not exist. \"\n \"Run 'sudo nixos-rebuild switch' first.\"\n )\n # Curator reports dir is a sub-path of logs/; create it if missing.\n # In managed mode the activation script may not know about this subdir,\n # so we mkdir it ourselves (it's inside an already-secured logs/ dir).\n (home / \"logs\" / \"curator\").mkdir(parents=True, exist_ok=True)\n # Inside umask(0o007) scope — SOUL.md will be created as 0660\n _ensure_default_soul_md(home)\n\n\n# =============================================================================\n# Config loading/saving\n# =============================================================================\n\nDEFAULT_CONFIG = {\n \"model\": \"\",\n \"providers\": {},\n \"fallback_providers\": [],\n \"credential_pool_strategies\": {},\n \"toolsets\": [\"hermes-cli\"],\n \"agent\": {\n \"max_turns\": 90,\n # Inactivity timeout for gateway agent execution (seconds).\n # The agent can run indefinitely as long as it's actively calling\n # tools or receiving API responses. Only fires when the agent has\n # been completely idle for this duration. 0 = unlimited.\n \"gateway_timeout\": 1800,\n # Graceful drain timeout for gateway stop/restart (seconds).\n # The gateway stops accepting new work, waits for running agents\n # to finish, then interrupts any remaining runs after the timeout.\n # 0 = no drain, interrupt immediately.\n #\n # 180s is calibrated for realistic in-flight agent turns: a typical\n # coding conversation mid-reasoning runs 60–150s per call, so a 60s\n # budget routinely interrupted legitimate work on /restart. Raise\n # further in config.yaml if you run very-long-reasoning models.\n \"restart_drain_timeout\": 180,\n # Max app-level retry attempts for API errors (connection drops,\n # provider timeouts, 5xx, etc.) before the agent surfaces the\n # failure. The OpenAI SDK already does its own low-level retries\n # (max_retries=2 default) for transient network errors; this is\n # the Hermes-level retry loop that wraps the whole call. Lower\n # this to 1 if you use fallback providers and want fast failover\n # on flaky primaries; raise it if you prefer to tolerate longer\n # provider hiccups on a single provider.\n \"api_max_retries\": 3,\n \"service_tier\": \"\",\n # Tool-use enforcement: injects system prompt guidance that tells the\n # model to actually call tools instead of describing intended actions.\n # Values: \"auto\" (default — applies to gpt/codex models), true/false\n # (force on/off for all models), or a list of model-name substrings\n # to match (e.g. [\"gpt\", \"codex\", \"gemini\", \"qwen\"]).\n \"tool_use_enforcement\": \"auto\",\n # Staged inactivity warning: send a warning to the user at this\n # threshold before escalating to a full timeout. The warning fires\n # once per run and does not interrupt the agent. 0 = disable warning.\n \"gateway_timeout_warning\": 900,\n # Periodic \"still working\" notification interval (seconds).\n # Sends a status message every N seconds so the user knows the\n # agent hasn't died during long tasks. 0 = disable notifications.\n # Lower values mean faster feedback on slow tasks but more chat\n # noise; 180s is a compromise that catches spinning weak-model runs\n # (60+ tool iterations with tiny output) before users assume the\n # bot is dead and /restart.\n \"gateway_notify_interval\": 180,\n # Freshness window for the gateway auto-continue note (seconds).\n # After a gateway crash/restart/SIGTERM mid-run, the next user\n # message gets a \"[System note: your previous turn was\n # interrupted — process the unfinished tool result(s) first]\"\n # prepended so the model picks up where it left off. That's the\n # right behaviour while the interruption is fresh, but stale\n # markers (transcript last touched hours or days ago) can revive\n # an unrelated old task when the user's next message starts new\n # work. This window is the max age of the last persisted\n # transcript row for which we still inject the continue note.\n # Default 3600s comfortably covers a long turn (gateway_timeout\n # default is 1800s) plus runtime slack. Set to 0 to disable the\n # gate and restore pre-fix behaviour (always inject).\n \"gateway_auto_continue_freshness\": 3600,\n # How user-attached images are presented to the main model on each turn.\n # \"auto\" — attach natively when the active model reports\n # supports_vision=True AND the user hasn't explicitly\n # configured auxiliary.vision.provider. Otherwise fall\n # back to text (vision_analyze pre-analysis).\n # \"native\" — always attach natively; non-vision models will either\n # error at the provider or get a last-chance text fallback\n # (see run_agent._prepare_messages_for_api).\n # \"text\" — always pre-analyze with vision_analyze and prepend the\n # description as text; the main model never sees pixels.\n # Affects gateway platforms, the TUI, and CLI /attach. vision_analyze\n # remains available as a tool regardless of this setting — the routing\n # only controls how inbound user images are presented.\n \"image_input_mode\": \"auto\",\n \"disabled_toolsets\": [],\n },\n \n \"terminal\": {\n \"backend\": \"local\",\n \"modal_mode\": \"auto\",\n \"cwd\": \".\", # Use current directory\n \"timeout\": 180,\n # Environment variables to pass through to sandboxed execution\n # (terminal and execute_code). Skill-declared required_environment_variables\n # are passed through automatically; this list is for non-skill use cases.\n \"env_passthrough\": [],\n # Extra files to source in the login shell when building the\n # per-session environment snapshot. Use this when tools like nvm,\n # pyenv, asdf, or custom PATH entries are registered by files that\n # a bash login shell would skip — most commonly ``~/.bashrc``\n # (bash doesn't source bashrc in non-interactive login mode) or\n # zsh-specific files like ``~/.zshrc`` / ``~/.zprofile``.\n # Paths support ``~`` / ``${VAR}``. Missing files are silently\n # skipped. When empty, Hermes auto-sources ``~/.profile``,\n # ``~/.bash_profile``, and ``~/.bashrc`` (in that order) if the\n # snapshot shell is bash (this is the ``auto_source_bashrc``\n # behaviour — disable with that key if you want strict login-only\n # semantics).\n \"shell_init_files\": [],\n # When true (default), Hermes sources the user's shell rc files\n # (``~/.profile``, ``~/.bash_profile``, ``~/.bashrc``) in the\n # login shell used to build the environment snapshot. This\n # captures PATH additions, shell functions, and aliases — which a\n # plain ``bash -l -c`` would otherwise miss because bash skips\n # bashrc in non-interactive login mode, and because a default\n # Debian/Ubuntu ``~/.bashrc`` short-circuits on non-interactive\n # sources. ``~/.profile`` and ``~/.bash_profile`` are tried first\n # because ``n`` / ``nvm`` / ``asdf`` installers typically write\n # their PATH exports there without an interactivity guard. Turn\n # this off if your rc files misbehave when sourced\n # non-interactively (e.g. one that hard-exits on TTY checks).\n \"auto_source_bashrc\": True,\n \"docker_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"docker_forward_env\": [],\n # Explicit environment variables to set inside Docker containers.\n # Unlike docker_forward_env (which reads values from the host process),\n # docker_env lets you specify exact key-value pairs — useful when Hermes\n # runs as a systemd service without access to the user's shell environment.\n # Example: {\"SSH_AUTH_SOCK\": \"/run/user/1000/ssh-agent.sock\"}\n \"docker_env\": {},\n \"singularity_image\": \"docker://nikolaik/python-nodejs:python3.11-nodejs20\",\n \"modal_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"daytona_image\": \"nikolaik/python-nodejs:python3.11-nodejs20\",\n \"vercel_runtime\": \"node24\",\n # Container resource limits (docker, singularity, modal, daytona, vercel_sandbox — ignored for local/ssh)\n \"container_cpu\": 1,\n \"container_memory\": 5120, # MB (default 5GB)\n \"container_disk\": 51200, # MB (default 50GB)\n \"container_persistent\": True, # Persist filesystem across sessions\n # Docker volume mounts — share host directories with the container.\n # Each entry is \"host_path:container_path\" (standard Docker -v syntax).\n # Example:\n # [\"/home/user/projects:/workspace/projects\",\n # \"/home/user/.hermes/cache/documents:/output\"]\n # For gateway MEDIA delivery, write inside Docker to /output/... and emit\n # the host-visible path in MEDIA:, not the container path.\n \"docker_volumes\": [],\n # Explicit opt-in: mount the host cwd into /workspace for Docker sessions.\n # Default off because passing host directories into a sandbox weakens isolation.\n \"docker_mount_cwd_to_workspace\": False,\n \"docker_extra_args\": [], # Extra flags passed verbatim to docker run\n # Explicit opt-in: run the Docker container as the host user's uid:gid\n # (via `--user`). When enabled, files written into bind-mounted dirs\n # (docker_volumes, the persistent workspace, or the auto-mounted cwd)\n # are owned by your host user instead of root, which avoids needing\n # `sudo chown` after container runs. Default off to preserve behavior\n # for images whose entrypoints expect to start as root (e.g. the\n # bundled Hermes image, which drops to the `hermes` user via gosu).\n # When on, SETUID/SETGID caps are omitted from the container since\n # no privilege drop is needed.\n \"docker_run_as_host_user\": False,\n # Persistent shell — keep a long-lived bash shell across execute() calls\n # so cwd/env vars/shell variables survive between commands.\n # Enabled by default for non-local backends (SSH); local is always opt-in\n # via TERMINAL_LOCAL_PERSISTENT env var.\n \"persistent_shell\": True,\n },\n\n \"web\": {\n \"backend\": \"\", # shared fallback — applies to both search and extract\n \"search_backend\": \"\", # per-capability override for web_search (e.g. \"searxng\")\n \"extract_backend\": \"\", # per-capability override for web_extract (e.g. \"native\")\n },\n\n \"browser\": {\n \"inactivity_timeout\": 120,\n \"command_timeout\": 30, # Timeout for browser commands in seconds (screenshot, navigate, etc.)\n \"record_sessions\": False, # Auto-record browser sessions as WebM videos\n \"allow_private_urls\": False, # Allow navigating to private/internal IPs (localhost, 192.168.x.x, etc.)\n # Browser engine for local mode. Passed as ``--engine `` to\n # agent-browser v0.25.3+.\n # \"auto\" — use Chrome (default, don't pass --engine at all)\n # \"lightpanda\" — use Lightpanda (1.3-5.8x faster navigation, no screenshots)\n # \"chrome\" — explicitly request Chrome\n # Also settable via AGENT_BROWSER_ENGINE env var.\n \"engine\": \"auto\",\n \"auto_local_for_private_urls\": True, # When a cloud provider is set, auto-spawn local Chromium for LAN/localhost URLs instead of sending them to the cloud\n \"cdp_url\": \"\", # Optional persistent CDP endpoint for attaching to an existing Chromium/Chrome\n # CDP supervisor — dialog + frame detection via a persistent WebSocket.\n # Active only when a CDP-capable backend is attached (Browserbase or\n # local Chrome via /browser connect). See\n # website/docs/developer-guide/browser-supervisor.md.\n \"dialog_policy\": \"must_respond\", # must_respond | auto_dismiss | auto_accept\n \"dialog_timeout_s\": 300, # Safety auto-dismiss after N seconds under must_respond\n \"camofox\": {\n # When true, Hermes sends a stable profile-scoped userId to Camofox\n # so the server maps it to a persistent Firefox profile automatically.\n # When false (default), each session gets a random userId (ephemeral).\n \"managed_persistence\": False,\n },\n },\n\n # Filesystem checkpoints — automatic snapshots before destructive file ops.\n # When enabled, the agent takes a snapshot of the working directory once\n # per conversation turn (on first write_file/patch call). Use /rollback\n # to restore.\n #\n # Defaults changed in v2 (single shared shadow store, real pruning):\n # - enabled: True -> False (opt-in; most users never use /rollback)\n # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite)\n # - auto_prune: False -> True (orphans/stale pruned automatically)\n # Opt in via ``hermes chat --checkpoints`` or set enabled=True here.\n \"checkpoints\": {\n \"enabled\": False,\n # Max checkpoints to keep per working directory. Pre-v2 this only\n # limited the `/rollback` listing; v2 actually rewrites the ref and\n # garbage-collects older commits.\n \"max_snapshots\": 20,\n # Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When\n # exceeded, the oldest checkpoint per project is dropped in a\n # round-robin pass until total size falls under the cap.\n # 0 disables the size cap.\n \"max_total_size_mb\": 500,\n # Skip any single file larger than this when staging a checkpoint.\n # Prevents accidental snapshotting of datasets, model weights, and\n # other large generated assets. 0 disables the filter.\n \"max_file_size_mb\": 10,\n # Auto-maintenance: hermes sweeps the checkpoint base at startup\n # (at most once per ``min_interval_hours``) and:\n # * deletes project entries whose workdir no longer exists (orphan)\n # * deletes project entries whose last_touch is older than\n # ``retention_days``\n # * GCs the single shared store to reclaim unreachable objects\n # * enforces ``max_total_size_mb`` across remaining projects\n # * deletes ``legacy-*`` archives older than ``retention_days``\n \"auto_prune\": True,\n \"retention_days\": 7,\n \"delete_orphans\": True,\n \"min_interval_hours\": 24,\n },\n\n # Maximum characters returned by a single read_file call. Reads that\n # exceed this are rejected with guidance to use offset+limit.\n # 100K chars ≈ 25–35K tokens across typical tokenisers.\n \"file_read_max_chars\": 100_000,\n\n # Tool-output truncation thresholds. When terminal output or a\n # single read_file page exceeds these limits, Hermes truncates the\n # payload sent to the model (keeping head + tail for terminal,\n # enforcing pagination for read_file). Tuning these trades context\n # footprint against how much raw output the model can see in one\n # shot. Ported from anomalyco/opencode PR #23770.\n #\n # - max_bytes: terminal_tool output cap, in chars\n # (default 50_000 ≈ 12-15K tokens).\n # - max_lines: read_file pagination cap — the maximum `limit`\n # a single read_file call can request before\n # being clamped (default 2000).\n # - max_line_length: per-line cap applied when read_file emits a\n # line-numbered view (default 2000 chars).\n \"tool_output\": {\n \"max_bytes\": 50_000,\n \"max_lines\": 2000,\n \"max_line_length\": 2000,\n },\n\n # Tool loop guardrails nudge models when they repeat failed or\n # non-progressing tool calls. Soft warnings are always-on by default;\n # hard stops are opt-in so interactive CLI/TUI sessions keep flowing.\n \"tool_loop_guardrails\": {\n \"warnings_enabled\": True,\n \"hard_stop_enabled\": False,\n \"warn_after\": {\n \"exact_failure\": 2,\n \"same_tool_failure\": 3,\n \"idempotent_no_progress\": 2,\n },\n \"hard_stop_after\": {\n \"exact_failure\": 5,\n \"same_tool_failure\": 8,\n \"idempotent_no_progress\": 5,\n },\n },\n\n \"compression\": {\n \"enabled\": True,\n \"threshold\": 0.50, # compress when context usage exceeds this ratio\n \"target_ratio\": 0.20, # fraction of threshold to preserve as recent tail\n \"protect_last_n\": 20, # minimum recent messages to keep uncompressed\n \"hygiene_hard_message_limit\": 400, # gateway session-hygiene force-compress threshold by message count\n },\n\n # Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).\n # cache_ttl must be \"5m\" or \"1h\" (Anthropic-supported tiers); other values are ignored.\n # long_lived_prefix: when true (default), Claude on Anthropic / OpenRouter / Nous\n # Portal uses a split layout: tools[-1] + stable system prefix at long_lived_ttl\n # (cross-session cache), last 2 messages at cache_ttl (within-session rolling).\n # Set false to keep the legacy \"system + last 3 messages\" single-tier layout.\n # long_lived_ttl: TTL for the cross-session prefix tier (\"5m\" or \"1h\"; default \"1h\").\n \"prompt_caching\": {\n \"cache_ttl\": \"5m\",\n \"long_lived_prefix\": True,\n \"long_lived_ttl\": \"1h\",\n },\n\n # OpenRouter-specific settings.\n # response_cache: enable OpenRouter response caching (X-OpenRouter-Cache header).\n # When enabled, identical requests return cached responses for free (zero billing).\n # This is separate from Anthropic prompt caching and works alongside it.\n # See: https://openrouter.ai/docs/guides/features/response-caching\n # response_cache_ttl: how long cached responses remain valid, in seconds (1-86400).\n # Default 300 (5 minutes). Only used when response_cache is enabled.\n # min_coding_score: knob for the openrouter/pareto-code router (0.0-1.0).\n # Only applied when model.model is \"openrouter/pareto-code\". Higher\n # values route to stronger (more expensive) coders; lower values open\n # up cheaper, faster options. Default 0.65 lands on the mid-tier\n # coder on the current Pareto frontier. Empty string = let OpenRouter\n # pick the strongest available coder (router's documented default\n # when the plugins block is omitted).\n # See: https://openrouter.ai/docs/guides/routing/routers/pareto-router\n \"openrouter\": {\n \"response_cache\": True,\n \"response_cache_ttl\": 300,\n \"min_coding_score\": 0.65,\n },\n\n # AWS Bedrock provider configuration.\n # Only used when model.provider is \"bedrock\".\n \"bedrock\": {\n \"region\": \"\", # AWS region for Bedrock API calls (empty = AWS_REGION env var → us-east-1)\n \"discovery\": {\n \"enabled\": True, # Auto-discover models via ListFoundationModels\n \"provider_filter\": [], # Only show models from these providers (e.g. [\"anthropic\", \"amazon\"])\n \"refresh_interval\": 3600, # Cache discovery results for this many seconds\n },\n \"guardrail\": {\n # Amazon Bedrock Guardrails — content filtering and safety policies.\n # Create a guardrail in the Bedrock console, then set the ID and version here.\n # See: https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails.html\n \"guardrail_identifier\": \"\", # e.g. \"abc123def456\"\n \"guardrail_version\": \"\", # e.g. \"1\" or \"DRAFT\"\n \"stream_processing_mode\": \"async\", # \"sync\" or \"async\"\n \"trace\": \"disabled\", # \"enabled\", \"disabled\", or \"enabled_full\"\n },\n },\n\n # Auxiliary model config — provider:model for each side task.\n # Format: provider is the provider name, model is the model slug.\n # \"auto\" for provider = auto-detect best available provider.\n # Empty model = use provider's default auxiliary model.\n # All tasks fall back to openrouter:google/gemini-3-flash-preview if\n # the configured provider is unavailable.\n #\n # extra_body: forwarded verbatim as request body fields on every aux call\n # for that task. Use this to set provider-specific knobs (independent of\n # main-agent settings). On OpenRouter you can set provider routing prefs\n # and the Pareto Code coding-score floor here. Example:\n #\n # auxiliary:\n # compression:\n # provider: openrouter\n # model: openrouter/pareto-code\n # extra_body:\n # provider: # OpenRouter provider routing\n # order: [anthropic, google]\n # sort: throughput # or price | latency\n # plugins: # OpenRouter Pareto Code router\n # - id: pareto-router\n # min_coding_score: 0.5\n #\n # Each aux task is independent — main-agent provider_routing and\n # openrouter.min_coding_score do NOT propagate to aux calls by design.\n \"auxiliary\": {\n \"vision\": {\n \"provider\": \"auto\", # auto | openrouter | nous | codex | custom\n \"model\": \"\", # e.g. \"google/gemini-2.5-flash\", \"gpt-4o\"\n \"base_url\": \"\", # direct OpenAI-compatible endpoint (takes precedence over provider)\n \"api_key\": \"\", # API key for base_url (falls back to OPENAI_API_KEY)\n \"timeout\": 120, # seconds — LLM API call timeout; vision payloads need generous timeout\n \"extra_body\": {}, # OpenAI-compatible provider-specific request fields\n \"download_timeout\": 30, # seconds — image HTTP download timeout; increase for slow connections\n },\n \"web_extract\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 360, # seconds (6min) — per-attempt LLM summarization timeout; increase for slow local models\n \"extra_body\": {},\n },\n \"compression\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120, # seconds — compression summarises large contexts; increase for local models\n \"extra_body\": {},\n },\n \"session_search\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n \"max_concurrency\": 3, # Clamp parallel summaries to avoid request-burst 429s on small providers\n },\n \"skills_hub\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"approval\": {\n \"provider\": \"auto\",\n \"model\": \"\", # fast/cheap model recommended (e.g. gemini-flash, haiku)\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"mcp\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n \"title_generation\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 30,\n \"extra_body\": {},\n },\n # Triage specifier — flesh out a rough one-liner in the Kanban\n # Triage column into a concrete spec, then promote it to ``todo``.\n # Invoked by ``hermes kanban specify`` (single id or --all). Set a\n # cheap, capable model here (gemini-flash works well); the main\n # model is overkill for short spec expansion.\n \"triage_specifier\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 120,\n \"extra_body\": {},\n },\n # Curator — skill-usage review fork. Timeout is generous because the\n # review pass can take several minutes on reasoning models (umbrella\n # building over hundreds of candidate skills). \"auto\" = use main chat\n # model; override via `hermes model` → auxiliary → Curator to route\n # to a cheaper aux model (e.g. openrouter google/gemini-3-flash-preview).\n \"curator\": {\n \"provider\": \"auto\",\n \"model\": \"\",\n \"base_url\": \"\",\n \"api_key\": \"\",\n \"timeout\": 600,\n \"extra_body\": {},\n },\n },\n \n \"display\": {\n \"compact\": False,\n \"personality\": \"kawaii\",\n \"resume_display\": \"full\",\n \"busy_input_mode\": \"interrupt\", # interrupt | queue | steer\n # When true, `hermes --tui` auto-resumes the most recent human-\n # facing session on launch instead of forging a fresh one.\n # Mirrors `hermes -c` muscle memory. Default off so existing\n # users aren't surprised. HERMES_TUI_RESUME= always wins.\n \"tui_auto_resume_recent\": False,\n \"bell_on_complete\": False,\n \"show_reasoning\": False,\n \"streaming\": False,\n \"timestamps\": False, # Show [HH:MM] on user and assistant labels\n \"final_response_markdown\": \"strip\", # render | strip | raw\n # Preserve recent classic CLI output across Ctrl+L, /redraw, and\n # terminal resize full-screen clears. Disable if a terminal emulator\n # behaves badly with replayed scrollback.\n \"persistent_output\": True,\n \"persistent_output_max_lines\": 200,\n \"inline_diffs\": True, # Show inline diff previews for write actions (write_file, patch, skill_manage)\n \"show_cost\": False, # Show $ cost in the status bar (off by default)\n \"skin\": \"default\",\n # UI language for static user-facing messages (approval prompts, a\n # handful of gateway slash-command replies). Does NOT affect agent\n # responses, log lines, tool outputs, or slash-command descriptions.\n # Supported: en, zh, ja, de, es, fr, tr, uk. Unknown values fall back to en.\n \"language\": \"en\",\n # TUI busy indicator style: kaomoji (default), emoji, unicode (braille\n # spinner), or ascii. Live-swappable via `/indicator