From f5f41a0921fdf8d63f582e8ca5eb88e32670001b Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 12 Jun 2026 15:04:05 -0400 Subject: [PATCH] feat: refactor doctor, unify pip install and project root --- acp_adapter/entry.py | 3 +- agent/i18n.py | 3 +- agent/lsp/install.py | 5 +- cron/scheduler.py | 1 + gateway/config.py | 3 +- gateway/run.py | 8 +- gateway/slash_commands.py | 3 +- hermes_cli/banner.py | 18 +- hermes_cli/build_info.py | 3 +- hermes_cli/claw.py | 3 +- hermes_cli/config.py | 17 +- hermes_cli/cron.py | 3 +- hermes_cli/dingtalk_auth.py | 14 +- hermes_cli/doctor.py | 2290 ----------------- hermes_cli/doctor/__init__.py | 165 ++ hermes_cli/doctor/_output.py | 94 + hermes_cli/doctor/_registry.py | 356 +++ hermes_cli/doctor/_types.py | 65 + hermes_cli/doctor/checks/__init__.py | 19 + hermes_cli/doctor/checks/_helpers.py | 123 + hermes_cli/doctor/checks/api_connectivity.py | 304 +++ hermes_cli/doctor/checks/auth_providers.py | 67 + hermes_cli/doctor/checks/command_install.py | 84 + hermes_cli/doctor/checks/config_files.py | 334 +++ hermes_cli/doctor/checks/dep_mgmt.py | 85 + .../doctor/checks/directory_structure.py | 155 ++ hermes_cli/doctor/checks/external_tools.py | 274 ++ hermes_cli/doctor/checks/gateway_service.py | 54 + hermes_cli/doctor/checks/github_check.py | 36 + hermes_cli/doctor/checks/memory_provider.py | 73 + hermes_cli/doctor/checks/profiles.py | 42 + hermes_cli/doctor/checks/python_env.py | 71 + hermes_cli/doctor/checks/security.py | 47 + hermes_cli/doctor/checks/skills_hub.py | 56 + hermes_cli/doctor/checks/tool_availability.py | 78 + hermes_cli/doctor/checks/xai_retirement.py | 24 + hermes_cli/dump.py | 8 +- hermes_cli/gateway.py | 42 +- hermes_cli/gateway_windows.py | 3 +- hermes_cli/main.py | 89 +- hermes_cli/managed_uv.py | 50 +- hermes_cli/mcp_catalog.py | 3 +- hermes_cli/memory_setup.py | 17 +- hermes_cli/nous_subscription.py | 3 +- hermes_cli/plugins.py | 3 +- hermes_cli/profiles.py | 3 +- hermes_cli/proxy/cli.py | 2 +- hermes_cli/proxy/server.py | 4 +- hermes_cli/setup.py | 34 +- hermes_cli/status.py | 3 +- hermes_cli/tools_config.py | 8 +- hermes_cli/uninstall.py | 7 +- hermes_cli/web_server.py | 3 +- hermes_constants.py | 22 + plugins/google_meet/cli.py | 15 +- plugins/memory/honcho/cli.py | 9 +- plugins/platforms/discord/adapter.py | 2 +- plugins/platforms/google_chat/adapter.py | 2 +- plugins/platforms/google_chat/oauth.py | 11 +- providers/__init__.py | 3 +- pyproject.toml | 96 +- scripts/check-windows-footguns.py | 3 +- scripts/check_subprocess_stdin.py | 3 +- scripts/profile-tui.py | 5 +- scripts/release.py | 3 +- scripts/run_tests_parallel.py | 3 +- scripts/sample_and_compress.py | 3 +- .../google-workspace/scripts/setup.py | 11 +- tests/agent/test_auxiliary_config_bridge.py | 6 +- tests/agent/test_bedrock_integration.py | 3 +- .../gateway/test_plugin_platform_interface.py | 3 +- tests/gateway/test_session_state_cleanup.py | 3 +- tests/hermes_cli/test_cmd_update.py | 35 +- tests/hermes_cli/test_curses_color_compat.py | 3 +- tests/hermes_cli/test_doctor.py | 24 +- tests/hermes_cli/test_gui_uninstall.py | 4 +- .../test_kanban_cli_dispatch_passthrough.py | 10 +- .../hermes_cli/test_pip_install_detection.py | 10 +- .../test_signal_handler_kanban_worker.py | 5 +- tests/hermes_cli/test_uv_tool_update.py | 126 +- tests/run_agent/test_callable_api_key.py | 19 +- tests/test_atomic_replace_symlinks.py | 3 +- ...t_dashboard_sidecar_close_on_disconnect.py | 3 +- tests/test_desktop_mac_entitlements.py | 3 +- tests/test_docker_home_override_scripts.py | 3 +- tests/test_dockerfile_tini_compat_shim.py | 3 +- tests/test_evidence_store.py | 3 +- tests/test_install_no_initial_commit.py | 3 +- tests/test_install_sh_browser_install.py | 3 +- ...test_install_sh_pythonpath_sanitization.py | 3 +- ...test_install_sh_root_fhs_uv_python_path.py | 3 +- .../test_install_sh_setup_wizard_tty_probe.py | 3 +- tests/test_install_sh_symlink_stomp.py | 3 +- .../test_install_sh_termux_network_prereqs.py | 3 +- tests/test_lint_config.py | 3 +- tests/test_package_json_lazy_deps.py | 3 +- tests/test_run_tests_parallel.py | 6 +- tests/test_termux_all_extra_compat.py | 3 +- tests/test_tui_gateway_server.py | 1 + tests/tools/test_windows_compat.py | 3 +- tools/browser_tool.py | 3 +- tools/cronjob_tools.py | 1 + tools/lazy_deps.py | 1 - tools/mcp_tool.py | 4 +- tools/send_message_tool.py | 4 +- tools/skills_hub.py | 4 +- tools/skills_sync.py | 6 +- tools/tts_tool.py | 2 +- tools/voice_mode.py | 2 +- tui_gateway/server.py | 2 +- 110 files changed, 3094 insertions(+), 2697 deletions(-) delete mode 100644 hermes_cli/doctor.py create mode 100644 hermes_cli/doctor/__init__.py create mode 100644 hermes_cli/doctor/_output.py create mode 100644 hermes_cli/doctor/_registry.py create mode 100644 hermes_cli/doctor/_types.py create mode 100644 hermes_cli/doctor/checks/__init__.py create mode 100644 hermes_cli/doctor/checks/_helpers.py create mode 100644 hermes_cli/doctor/checks/api_connectivity.py create mode 100644 hermes_cli/doctor/checks/auth_providers.py create mode 100644 hermes_cli/doctor/checks/command_install.py create mode 100644 hermes_cli/doctor/checks/config_files.py create mode 100644 hermes_cli/doctor/checks/dep_mgmt.py create mode 100644 hermes_cli/doctor/checks/directory_structure.py create mode 100644 hermes_cli/doctor/checks/external_tools.py create mode 100644 hermes_cli/doctor/checks/gateway_service.py create mode 100644 hermes_cli/doctor/checks/github_check.py create mode 100644 hermes_cli/doctor/checks/memory_provider.py create mode 100644 hermes_cli/doctor/checks/profiles.py create mode 100644 hermes_cli/doctor/checks/python_env.py create mode 100644 hermes_cli/doctor/checks/security.py create mode 100644 hermes_cli/doctor/checks/skills_hub.py create mode 100644 hermes_cli/doctor/checks/tool_availability.py create mode 100644 hermes_cli/doctor/checks/xai_retirement.py diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 9ce6281824..aa4657a8a8 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -234,7 +234,8 @@ def main(argv: list[str] | None = None) -> None: logger.info("Starting hermes-agent ACP adapter") # Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works - project_root = str(Path(__file__).resolve().parent.parent) + from hermes_constants import get_hermes_source_root + project_root = str(get_hermes_source_root()) if project_root not in sys.path: sys.path.insert(0, project_root) diff --git a/agent/i18n.py b/agent/i18n.py index ef9fd4b06c..5faaa3e446 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -115,7 +115,8 @@ def _locales_dir() -> Path: ) # agent/i18n.py -> agent/ -> repo root (source checkout, editable install) - source_dir = Path(__file__).resolve().parent.parent / "locales" + from hermes_constants import get_hermes_source_root + source_dir = get_hermes_source_root() / "locales" if source_dir.is_dir(): return source_dir diff --git a/agent/lsp/install.py b/agent/lsp/install.py index bc8e196fda..ab6e4e760a 100644 --- a/agent/lsp/install.py +++ b/agent/lsp/install.py @@ -35,8 +35,6 @@ import threading from pathlib import Path from typing import Any, Dict, Optional -from hermes_cli.managed_uv import get_pip_cmd - logger = logging.getLogger("agent.lsp.install") # Package-name → install-strategy hint registry. Each entry is a @@ -345,6 +343,9 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]: pip_target.mkdir(parents=True, exist_ok=True) try: logger.info("[install] pip install --target %s %s", pip_target, pkg) + # pip_install() can't be used here — needs --target to install outside + # the venv into a custom staging dir for LSP tool console scripts. + from hermes_cli.managed_uv import get_pip_cmd proc = subprocess.run( get_pip_cmd() + ["install", "--target", str(pip_target), "--quiet", pkg], check=False, diff --git a/cron/scheduler.py b/cron/scheduler.py index b784847dec..5caf34d373 100644 --- a/cron/scheduler.py +++ b/cron/scheduler.py @@ -36,6 +36,7 @@ from typing import List, Optional # Add parent directory to path for imports BEFORE repo-level imports. # Without this, standalone invocations (e.g. after `hermes update` reloads # the module) fail with ModuleNotFoundError for hermes_time et al. +# Bootstrap sys.path before imports — cannot use get_hermes_source_root() here yet. sys.path.insert(0, str(Path(__file__).parent.parent)) from hermes_constants import get_hermes_home diff --git a/gateway/config.py b/gateway/config.py index 33df3b1acf..7502951906 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -213,7 +213,8 @@ class Platform(Enum): """Return names of bundled platform plugins under ``plugins/platforms/``.""" names: set = set() try: - platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms" + from hermes_constants import get_hermes_source_root + platforms_dir = get_hermes_source_root() / "plugins" / "platforms" if platforms_dir.is_dir(): for child in platforms_dir.iterdir(): if ( diff --git a/gateway/run.py b/gateway/run.py index bd90e2d1ab..5910ef2404 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -895,7 +895,8 @@ os.environ["_HERMES_GATEWAY"] = "1" _ensure_ssl_certs() # Add parent directory to path -sys.path.insert(0, str(Path(__file__).parent.parent)) +from hermes_constants import get_hermes_source_root +sys.path.insert(0, str(get_hermes_source_root())) # Resolve Hermes home directory (respects HERMES_HOME override) from hermes_constants import get_hermes_home @@ -1551,7 +1552,8 @@ def _check_unavailable_skill(command_name: str) -> str | None: # Check optional skills (shipped with repo but not installed) from hermes_constants import get_optional_skills_dir - repo_root = Path(__file__).resolve().parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() optional_dir = get_optional_skills_dir(repo_root / "optional-skills") if optional_dir.exists(): for skill_md in optional_dir.rglob("SKILL.md"): @@ -6085,7 +6087,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew elif platform == Platform.SLACK: from gateway.platforms.slack import SlackAdapter, check_slack_requirements if not check_slack_requirements(): - logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'") + logger.warning("Slack: slack-bolt not installed. Run: uv pip install -e '.[slack]' (from the hermes-agent checkout)") return None return SlackAdapter(config) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 107b5645ec..a9af4ecbf3 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3431,7 +3431,8 @@ class GatewaySlashCommandsMixin: if is_managed(): return f"✗ {format_managed_message('update Hermes Agent')}" - project_root = Path(__file__).parent.parent.resolve() + from hermes_constants import get_hermes_source_root + project_root = get_hermes_source_root() git_dir = project_root / '.git' if not git_dir.exists(): diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1955b009df..1554cbf336 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -268,7 +268,8 @@ def check_for_updates() -> Optional[int]: # Prefer the running code's location over the profile-scoped path. # $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all; # Path(__file__) always resolves to the actual installed checkout. - repo_dir = Path(__file__).parent.parent.resolve() + from hermes_constants import get_hermes_source_root + repo_dir = get_hermes_source_root() if not (repo_dir / ".git").exists(): repo_dir = hermes_home / "hermes-agent" if not (repo_dir / ".git").exists(): @@ -293,7 +294,8 @@ def _resolve_repo_dir() -> Optional[Path]: because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried over by ``--clone-all``. """ - repo_dir = Path(__file__).parent.parent.resolve() + from hermes_constants import get_hermes_source_root + repo_dir = get_hermes_source_root() if not (repo_dir / ".git").exists(): hermes_home = get_hermes_home() repo_dir = hermes_home / "hermes-agent" @@ -728,17 +730,15 @@ def build_welcome_banner(console: "Console", model: str, cwd: str, except Exception: pass # Never break the banner over an update check - # Pip-install warning — `pip install hermes-agent` is not the supported - # install path (it exists on PyPI for internal/CI reasons, not end users). - # Such installs miss the git checkout + installer-managed deps, so updates, - # self-update, and issue triage don't behave correctly. Warn, don't block. + # PyPI install warning — `pip install hermes-agent` is not a supported + # install path. Direct users to the official installer. try: from hermes_cli.config import detect_install_method if detect_install_method() == "pip": right_lines.append( - "[bold yellow]⚠ pip install not officially supported[/]" - "[dim yellow] — exists for reasons other than user install; " - "expect instability and an inability to support issues[/]" + "[bold yellow]⚠ the hermes-agent python package is no longer supported[/]" + "[dim yellow] please reinstall via our official installer " + "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash[/]" ) except Exception: pass # Never break the banner over the install-method check diff --git a/hermes_cli/build_info.py b/hermes_cli/build_info.py index e4cc6f0997..59fd1f7d86 100644 --- a/hermes_cli/build_info.py +++ b/hermes_cli/build_info.py @@ -27,10 +27,11 @@ from __future__ import annotations from pathlib import Path from typing import Optional +from hermes_constants import get_hermes_source_root # Path is resolved relative to this module so it works regardless of cwd — # matches the pattern used by ``banner._resolve_repo_dir``. -_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha" +_BUILD_SHA_FILE = get_hermes_source_root() / ".hermes_build_sha" def get_build_sha(short: int = 8) -> Optional[str]: diff --git a/hermes_cli/claw.py b/hermes_cli/claw.py index 792e35c168..e2c7aa1713 100644 --- a/hermes_cli/claw.py +++ b/hermes_cli/claw.py @@ -32,7 +32,8 @@ from hermes_cli.setup import ( logger = logging.getLogger(__name__) -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() _OPENCLAW_SCRIPT = ( get_optional_skills_dir(PROJECT_ROOT / "optional-skills") diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 494c5ddfe3..9676c11e4b 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -379,7 +379,8 @@ def detect_install_method(project_root: Optional[Path] = None) -> str: if managed: return managed.lower().replace(" ", "-") if project_root is None: - project_root = Path(__file__).parent.parent.resolve() + from hermes_constants import get_hermes_source_root + project_root = get_hermes_source_root() if (project_root / ".git").is_dir(): return "git" return "pip" @@ -435,9 +436,10 @@ def recommended_update_command_for_method(method: str) -> str: if is_uv_tool_install(): return "uv tool upgrade hermes-agent" import shutil - if shutil.which("uv"): - return "uv pip install --upgrade hermes-agent" - return "pip install --upgrade hermes-agent" + if shutil.which("pipx") and "pipx" in __import__("sys").prefix.split(__import__("os").sep): + return "pipx upgrade hermes-agent" + # PyPI-based installs are no longer supported — direct to the installer. + return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash" return "hermes update" @@ -600,9 +602,6 @@ def get_env_path() -> Path: """Get the .env file path (for API keys).""" return get_hermes_home() / ".env" -def get_project_root() -> Path: - """Get the project installation directory.""" - return Path(__file__).parent.parent.resolve() def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]: """Read the HERMES_UID / HERMES_GID env vars set by Docker deployments. @@ -5931,6 +5930,8 @@ def redact_key(key: str) -> str: def show_config(): """Display current configuration.""" + from hermes_constants import get_hermes_source_root + config = load_config() print() @@ -5943,7 +5944,7 @@ def show_config(): print(color("◆ Paths", Colors.CYAN, Colors.BOLD)) print(f" Config: {get_config_path()}") print(f" Secrets: {get_env_path()}") - print(f" Install: {get_project_root()}") + print(f" Install: {get_hermes_source_root()}") # API Keys print() diff --git a/hermes_cli/cron.py b/hermes_cli/cron.py index 683fc73fb7..5713956b9d 100644 --- a/hermes_cli/cron.py +++ b/hermes_cli/cron.py @@ -11,7 +11,8 @@ import sys from pathlib import Path from typing import Iterable, List, Optional -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() sys.path.insert(0, str(PROJECT_ROOT)) from hermes_cli.colors import Colors, color diff --git a/hermes_cli/dingtalk_auth.py b/hermes_cli/dingtalk_auth.py index 4686c46d07..7b4e706238 100644 --- a/hermes_cli/dingtalk_auth.py +++ b/hermes_cli/dingtalk_auth.py @@ -21,7 +21,7 @@ import logging from typing import Optional, Tuple import requests -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install logger = logging.getLogger(__name__) @@ -165,17 +165,13 @@ def _ensure_qrcode_installed() -> bool: import subprocess - # Try uv first (Hermes convention), then pip - for cmd in ( - get_pip_cmd() + ["install", "qrcode"], - get_pip_cmd() + ["install", "-q", "qrcode"], - ): + result = pip_install(["qrcode"], quiet=True) + if result.returncode == 0: try: - subprocess.check_call(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) import qrcode # noqa: F401,F811 return True - except (subprocess.CalledProcessError, ImportError, FileNotFoundError): - continue + except ImportError: + pass return False diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py deleted file mode 100644 index ee745f019d..0000000000 --- a/hermes_cli/doctor.py +++ /dev/null @@ -1,2290 +0,0 @@ -""" -Doctor command for hermes CLI. - -Diagnoses issues with Hermes Agent setup. -""" - -import os -import sys -import subprocess -import shutil -from pathlib import Path - -from hermes_cli.config import get_project_root, get_hermes_home, get_env_path -from hermes_cli.env_loader import load_hermes_dotenv -from hermes_cli.managed_uv import resolve_uv -from hermes_constants import display_hermes_home - -PROJECT_ROOT = get_project_root() -HERMES_HOME = get_hermes_home() -_DHH = display_hermes_home() # user-facing display path (e.g. ~/.hermes or ~/.hermes/profiles/coder) - -# Load environment variables from ~/.hermes/.env so API key checks work -_env_path = get_env_path() -load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env") - -from hermes_cli.colors import Colors, color -from hermes_cli.models import _HERMES_USER_AGENT -from hermes_constants import OPENROUTER_MODELS_URL -from utils import base_url_host_matches - - -_PROVIDER_ENV_HINTS = ( - "OPENROUTER_API_KEY", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "ANTHROPIC_TOKEN", - "OPENAI_BASE_URL", - "NOUS_API_KEY", - "GLM_API_KEY", - "ZAI_API_KEY", - "Z_AI_API_KEY", - "KIMI_API_KEY", - "KIMI_CN_API_KEY", - "GMI_API_KEY", - "MINIMAX_API_KEY", - "MINIMAX_CN_API_KEY", - "KILOCODE_API_KEY", - "DEEPSEEK_API_KEY", - "DASHSCOPE_API_KEY", - "HF_TOKEN", - "OPENCODE_ZEN_API_KEY", - "OPENCODE_GO_API_KEY", - "XIAOMI_API_KEY", - "TOKENHUB_API_KEY", -) - - -from hermes_constants import is_termux as _is_termux - - -def _python_install_cmd() -> str: - return "python -m pip install" if _is_termux() else "uv pip install" - - -def _system_package_install_cmd(pkg: str) -> str: - if _is_termux(): - return f"pkg install {pkg}" - if sys.platform == "darwin": - return f"brew install {pkg}" - return f"sudo apt install {pkg}" - - -def _safe_which(cmd: str) -> str | None: - """shutil.which wrapper resilient to platform monkeypatching in tests.""" - try: - return shutil.which(cmd) - except Exception: - return None - - -def _termux_browser_setup_steps(node_installed: bool) -> list[str]: - steps: list[str] = [] - step = 1 - if not node_installed: - steps.append(f"{step}) pkg install nodejs") - step += 1 - steps.append(f"{step}) npm install -g agent-browser") - steps.append(f"{step + 1}) agent-browser install") - return steps - - -def _termux_install_all_fallback_notes() -> list[str]: - return [ - "Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).", - "Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).", - "Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).", - "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).", - ] - - -def _has_provider_env_config(content: str) -> bool: - """Return True when ~/.hermes/.env contains provider auth/base URL settings.""" - return any(key in content for key in _PROVIDER_ENV_HINTS) - - -def _honcho_is_configured_for_doctor() -> bool: - """Return True when Honcho is configured, even if this process has no active session.""" - try: - from plugins.memory.honcho.client import HonchoClientConfig - - cfg = HonchoClientConfig.from_global_config() - return bool(cfg.enabled and (cfg.api_key or cfg.base_url)) - except Exception: - return False - - -def _is_kanban_worker_env_gate(item: dict) -> bool: - """Return True when Kanban is unavailable only because this is not a worker process.""" - if item.get("name") != "kanban": - return False - if os.environ.get("HERMES_KANBAN_TASK"): - return False - - tools = item.get("tools") or [] - return bool(tools) and all(str(tool).startswith("kanban_") for tool in tools) - - -def _doctor_tool_availability_detail(toolset: str) -> str: - """Optional explanatory suffix for toolsets whose doctor status needs context.""" - if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"): - return "(runtime-gated; loaded only for dispatcher-spawned workers)" - return "" - - -def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]: - """Adjust runtime-gated tool availability for doctor diagnostics.""" - updated_available = list(available) - updated_unavailable = [] - for item in unavailable: - name = item.get("name") - if _is_kanban_worker_env_gate(item): - if "kanban" not in updated_available: - updated_available.append("kanban") - continue - if name == "honcho" and _honcho_is_configured_for_doctor(): - if "honcho" not in updated_available: - updated_available.append("honcho") - continue - updated_unavailable.append(item) - return updated_available, updated_unavailable - - -def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool: - """Return True when a direct API-key probe failure is non-blocking. - - Some provider families support both a direct API-key path and a separate - OAuth runtime path. When the OAuth path is already healthy, doctor should - still show a failed API-key connectivity row, but it should not promote - that direct-key problem into the final blocking summary. - """ - normalized = (provider_label or "").strip().lower() - if normalized in {"google / gemini", "gemini"}: - try: - from hermes_cli.auth import get_gemini_oauth_auth_status - return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) - except Exception: - return False - if normalized == "minimax": - try: - from hermes_cli.auth import get_minimax_oauth_auth_status - return bool((get_minimax_oauth_auth_status() or {}).get("logged_in")) - except Exception: - return False - if normalized == "xai": - try: - from hermes_cli.auth import get_xai_oauth_auth_status - return bool((get_xai_oauth_auth_status() or {}).get("logged_in")) - except Exception: - return False - return False - - -def check_ok(text: str, detail: str = ""): - print(f" {color('✓', Colors.GREEN)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) - -def check_warn(text: str, detail: str = ""): - print(f" {color('⚠', Colors.YELLOW)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) - -def check_fail(text: str, detail: str = ""): - print(f" {color('✗', Colors.RED)} {text}" + (f" {color(detail, Colors.DIM)}" if detail else "")) - -def check_info(text: str): - print(f" {color('→', Colors.CYAN)} {text}") - - -def _section(title: str) -> None: - """Print a doctor section banner: blank line + bold cyan ◆ title.""" - print() - print(color(f"◆ {title}", Colors.CYAN, Colors.BOLD)) - - -def _fail_and_issue(text: str, detail: str, fix: str, issues: list[str]) -> None: - """Emit a check_fail and append the corresponding fix instruction.""" - check_fail(text, detail) - issues.append(fix) - - -def _read_pyproject_version() -> str | None: - """Read the ``version = "..."`` from ``pyproject.toml`` at the project root. - - Returns None when running from an installed wheel (no pyproject.toml ships - with the package) or when the file can't be parsed. Reads only the - ``[project]`` version, ignoring any version strings that appear in other - tables. - """ - pyproject = PROJECT_ROOT / "pyproject.toml" - try: - text = pyproject.read_text(encoding="utf-8") - except OSError: - return None - in_project = False - for raw in text.splitlines(): - line = raw.strip() - if line.startswith("[") and line.endswith("]"): - in_project = line == "[project]" - continue - if in_project and line.startswith("version") and "=" in line: - value = line.split("=", 1)[1] - value = value.split("#", 1)[0].strip().strip("\"'") - return value or None - return None - - -def _check_version_consistency(issues: list[str]) -> None: - """Verify pyproject.toml version matches hermes_cli.__version__. - - A git conflict resolution (reset/merge) can revert one file without the - other, leaving ``hermes --version`` reporting a stale version while - ``pyproject.toml`` is current. Detect that drift so users can re-sync. - Silent no-op for installed wheels where pyproject.toml isn't present. - """ - try: - from hermes_cli import __version__ as init_version - except Exception: - return - pyproject_version = _read_pyproject_version() - if pyproject_version is None: - # Installed wheel or unreadable pyproject — nothing to cross-check. - return - if pyproject_version == init_version: - check_ok("Version files consistent", f"({init_version})") - else: - _fail_and_issue( - "Version mismatch between source files", - f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})", - "Re-sync version files (e.g. run 'hermes update', or set " - "hermes_cli/__init__.py __version__ to match pyproject.toml)", - issues, - ) - - -def _check_s6_supervision(issues: list[str]) -> None: - """Inside a container under our s6 /init, surface what s6 sees. - - Runs as a counterpart to :func:`_check_gateway_service_linger` for - the systemd-on-host case. No-op everywhere except in the s6 - container so host runs aren't cluttered with irrelevant output. - - Reports: - - Whether the main-hermes and dashboard static services are up - - How many per-profile gateway slots are registered (via - ``S6ServiceManager.list_profile_gateways()``) and how many are - currently supervised as ``up`` - """ - try: - from hermes_cli.service_manager import ( - S6ServiceManager, - detect_service_manager, - ) - except Exception: - return - - if detect_service_manager() != "s6": - return - - _section("s6 Supervision") - - mgr = S6ServiceManager() - - # Static services. They live under /run/service/ via s6-rc symlinks, - # so the same s6-svstat probe works. - for static in ("main-hermes", "dashboard"): - if mgr.is_running(static): - check_ok(f"{static}: up") - else: - check_info(f"{static}: down (expected if not enabled via env)") - - profiles = mgr.list_profile_gateways() - if not profiles: - check_info("No per-profile gateways registered yet — create one with `hermes profile create `") - return - - up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}")) - check_ok( - f"Per-profile gateways: {up_count}/{len(profiles)} supervised up" - + (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "") - ) - - -def _check_gateway_service_linger(issues: list[str]) -> None: - """Warn when a systemd user gateway service will stop after logout. - - Skipped inside a container running under s6 — the linger concept - (user-systemd surviving SSH logout) doesn't apply there, and the - s6 supervision state is surfaced separately by - ``_check_s6_supervision``. - """ - try: - from hermes_cli.gateway import ( - get_systemd_linger_status, - get_systemd_unit_path, - is_linux, - ) - from hermes_cli.service_manager import detect_service_manager - except Exception as e: - check_warn("Gateway service linger", f"(could not import gateway helpers: {e})") - return - - if not is_linux(): - return - - # Inside a container under our s6 /init, _check_s6_supervision - # reports the live supervision state; the linger warning would be - # confusing here (no systemd, no logout, no "lingering" concept). - if detect_service_manager() == "s6": - return - - unit_path = get_systemd_unit_path() - if not unit_path.exists(): - return - - _section("Gateway Service") - linger_enabled, linger_detail = get_systemd_linger_status() - if linger_enabled is True: - check_ok("Systemd linger enabled", "(gateway service survives logout)") - elif linger_enabled is False: - check_warn("Systemd linger disabled", "(gateway may stop after logout)") - check_info("Run: sudo loginctl enable-linger $USER") - issues.append("Enable linger for the gateway user service: sudo loginctl enable-linger $USER") - else: - check_warn("Could not verify systemd linger", f"({linger_detail})") - - -_APIKEY_PROVIDERS_CACHE: list | None = None - - -def _build_apikey_providers_list() -> list: - """Build the API-key provider health-check list once and cache it. - - Tuple format: (name, env_vars, default_url, base_env, supports_models_endpoint) - Base list augmented with any ProviderProfile with auth_type="api_key" not - already present — adding plugins/model-providers// is sufficient to get into doctor. - """ - _static = [ - ("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), - ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True), - ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True), - ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True), - ("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True), - ("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True), - ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True), - ("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True), - ("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True), - ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True), - # MiniMax global: /v1 endpoint supports /models. - ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True), - # MiniMax CN: /v1 endpoint does NOT support /models (returns 404). - ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False), - ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), - ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), - # OpenCode Go has no shared /models endpoint; skip the health check. - ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False), - ] - _known_names = {t[0] for t in _static} - # Also index by profile canonical name so profiles without display_name - # don't create duplicate entries for providers already in the static list. - _known_canonical: set[str] = set() - _name_to_canonical = { - "Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding", - "StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn", - "Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek", - "Hugging Face": "huggingface", "NVIDIA NIM": "nvidia", - "Alibaba/DashScope": "alibaba", "MiniMax": "minimax", - "MiniMax (China)": "minimax-cn", - "Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen", - "OpenCode Go": "opencode-go", - } - for _label, _canonical in _name_to_canonical.items(): - _known_canonical.add(_canonical) - # Providers that already have a dedicated health check above the generic - # API-key loop (with custom headers/auth). Skip their pluggable profiles - # here so the generic Bearer-auth loop doesn't run a duplicate, broken - # check (e.g. Anthropic native API requires x-api-key, not Bearer). - _dedicated_canonical = {"anthropic", "openrouter", "bedrock"} - _known_canonical.update(_dedicated_canonical) - try: - from providers import list_providers - from providers.base import ProviderProfile as _PP - try: - from hermes_cli.providers import normalize_provider as _normalize_provider - except Exception: # pragma: no cover - normalization is best-effort - def _normalize_provider(_name: str) -> str: - return (_name or "").strip().lower() - for _pp in list_providers(): - if not isinstance(_pp, _PP) or _pp.auth_type != "api_key" or not _pp.env_vars: - continue - _label = _pp.display_name or _pp.name - if _label in _known_names or _pp.name in _known_canonical: - continue - _candidates = {_normalize_provider(_pp.name)} - for _alias in (_pp.aliases or ()): - _candidates.add(_normalize_provider(_alias)) - if _candidates & _dedicated_canonical: - continue - # Separate API-key vars from base-URL override vars — the health-check - # loop sends the first found value as Authorization: Bearer, so a URL - # string must never be picked. - _key_vars = tuple( - v for v in _pp.env_vars - if not v.endswith("_BASE_URL") and not v.endswith("_URL") - ) - _base_var = next( - (v for v in _pp.env_vars if v.endswith("_BASE_URL") or v.endswith("_URL")), - None, - ) - if not _key_vars: - continue - _models_url = ( - (_pp.models_url or (_pp.base_url.rstrip("/") + "/models")) - if _pp.base_url else None - ) - _hc = getattr(_pp, "supports_health_check", True) - _static.append((_label, _key_vars, _models_url, _base_var, _hc)) - except Exception: - pass - return _static - - -def run_doctor(args): - """Run diagnostic checks.""" - should_fix = getattr(args, 'fix', False) - ack_target = getattr(args, 'ack', None) - - # Doctor runs from the interactive CLI, so CLI-gated tool availability - # checks (like cronjob management) should see the same context as `hermes`. - os.environ.setdefault("HERMES_INTERACTIVE", "1") - - # Handle `hermes doctor --ack ` as a fast path. Persist the ack and - # return without running the rest of the diagnostics — the user has - # already seen the advisory and just wants to silence it. - if ack_target: - from hermes_cli.security_advisories import ( - ADVISORIES, - ack_advisory, - ) - valid_ids = {a.id for a in ADVISORIES} - if ack_target not in valid_ids: - print(color( - f"Unknown advisory ID: {ack_target!r}. Known IDs: " - f"{', '.join(sorted(valid_ids)) or '(none)'}", - Colors.RED, - )) - sys.exit(2) - if ack_advisory(ack_target): - print(color( - f" ✓ Acknowledged advisory {ack_target}. " - f"It will no longer trigger startup banners.", - Colors.GREEN, - )) - else: - print(color( - f" ✗ Failed to persist ack for {ack_target}. " - f"Check ~/.hermes/config.yaml is writable.", - Colors.RED, - )) - sys.exit(1) - return - - issues = [] - manual_issues = [] # issues that can't be auto-fixed - fixed_count = 0 - - print() - print(color("┌─────────────────────────────────────────────────────────┐", Colors.CYAN)) - print(color("│ 🩺 Hermes Doctor │", Colors.CYAN)) - print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN)) - print() - - # Check for explicitly unsupported platforms - if sys.platform == "darwin" and os.uname().machine == "x86_64": - print(color( - "⚠️ WARNING: macOS x86_64 (Intel) is explicitly unsupported.\n" - "We no longer accept PRs or provide fixes for this platform.\n" - "Consider migrating to a supported platform (macOS arm64 / Apple Silicon).", - Colors.YELLOW, - )) - print() - - _section("Security Advisories") - try: - from hermes_cli.security_advisories import ( - detect_compromised, - filter_unacked, - full_remediation_text, - get_acked_ids, - ) - all_hits = detect_compromised() - fresh_hits = filter_unacked(all_hits) - if fresh_hits: - for hit in fresh_hits: - check_fail( - f"{hit.advisory.title}", - f"({hit.package}=={hit.installed_version})", - ) - # Print the full remediation block, indented under the - # check_fail header so it reads as a single section. - for line in full_remediation_text(hit): - if line: - print(f" {color(line, Colors.YELLOW)}") - else: - print() - # Funnel into the action list so the summary block surfaces it - # for users who scroll past the section. - manual_issues.append( - f"Resolve security advisory {hit.advisory.id}: " - f"uninstall {hit.package}=={hit.installed_version} and " - f"rotate credentials, then run " - f"`hermes doctor --ack {hit.advisory.id}`." - ) - # Acked-but-still-installed: show as informational so the user - # knows the package is still on disk after the ack. - acked_ids = get_acked_ids() - for h in all_hits: - if h.advisory.id in acked_ids: - check_warn( - f"{h.package}=={h.installed_version} still installed " - f"(advisory {h.advisory.id} acknowledged)", - ) - else: - check_ok("No active security advisories") - except Exception as e: - # Never let a bug in the advisory check block the rest of doctor. - check_warn(f"Security advisory check failed: {e}") - - _section("Python Environment") - py_version = sys.version_info - if py_version >= (3, 11): - check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") - elif py_version >= (3, 10): - check_ok(f"Python {py_version.major}.{py_version.minor}.{py_version.micro}") - check_warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)") - else: - _fail_and_issue( - f"Python {py_version.major}.{py_version.minor}.{py_version.micro}", - "(3.10+ required)", - "Upgrade Python to 3.10+", - issues, - ) - - # Check if in virtual environment - in_venv = sys.prefix != sys.base_prefix - if in_venv: - check_ok("Virtual environment active") - else: - check_warn("Not in virtual environment", "(recommended)") - - # Detect drift between pyproject.toml and hermes_cli/__init__.py versions - # (a git conflict resolution can silently revert one but not the other). - _check_version_consistency(issues) - - _section("Virtual Environment Integrity") - venv_path = PROJECT_ROOT / "venv" - if not venv_path.exists(): - check_warn("Venv directory missing", "(will be recreated on next install/update)") - else: - # Check for legacy pip markers or missing uv - has_uv = bool(resolve_uv() or shutil.which("uv")) - if not has_uv: - check_fail( - "Legacy pip venv detected (uv missing)", - "(dependency management will fail. Run `hermes doctor --fix` to recreate)" - ) - if should_fix: - print(" -> Attempting atomic venv recreation...") - from hermes_cli.managed_uv import recreate_venv_atomically - if recreate_venv_atomically(PROJECT_ROOT, group="all"): - check_ok("Venv successfully recreated and swapped to uv-native state") - else: - check_fail("Venv recreation failed", "Please run the Hermes installer") - else: - check_ok("Venv structure valid and uv-native") - - _section("Dependency Management") - uv_bin = resolve_uv() - if uv_bin: - check_ok(f"Managed uv available ({uv_bin})") - else: - # Secondary check for system uv (e.g., Termux `pkg install uv`) - path_uv = shutil.which("uv") - if path_uv: - check_ok(f"System uv available ({path_uv})") - else: - check_fail( - "uv is missing", - "(dependency installation will fail. Install uv via the Hermes installer, or `pkg install uv` on Termux)", - ) - - _section("Required Packages") - required_packages = [ - ("openai", "OpenAI SDK"), - ("rich", "Rich (terminal UI)"), - ("dotenv", "python-dotenv"), - ("yaml", "PyYAML"), - ("httpx", "HTTPX"), - ] - - optional_packages = [ - ("croniter", "Croniter (cron expressions)"), - ("telegram", "python-telegram-bot"), - ("discord", "discord.py"), - ] - - for module, name in required_packages: - try: - __import__(module) - check_ok(name) - except ImportError: - _fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", issues) - - for module, name in optional_packages: - try: - __import__(module) - check_ok(name, "(optional)") - except ImportError: - check_warn(name, "(optional, not installed)") - - _section("Configuration Files") - # Check ~/.hermes/.env (primary location for user config) - env_path = HERMES_HOME / '.env' - if env_path.exists(): - check_ok(f"{_DHH}/.env file exists") - - # Check for common issues. Pin encoding to UTF-8 because .env files are - # written as UTF-8 everywhere in the codebase, while Path.read_text() - # defaults to the system locale — which crashes on non-UTF-8 Windows - # locales (e.g. GBK) as soon as the file contains any non-ASCII byte. - content = env_path.read_text(encoding="utf-8") - if _has_provider_env_config(content): - check_ok("API key or custom endpoint configured") - else: - check_warn(f"No API key found in {_DHH}/.env") - issues.append("Run 'hermes setup' to configure API keys") - else: - # Also check project root as fallback - fallback_env = PROJECT_ROOT / '.env' - if fallback_env.exists(): - check_ok(".env file exists (in project directory)") - else: - check_fail(f"{_DHH}/.env file missing") - if should_fix: - env_path.parent.mkdir(parents=True, exist_ok=True) - env_path.touch() - # .env holds API keys — restrict to owner-only access from - # creation. touch() obeys umask which is commonly 0o022, - # leaving the file world-readable; tighten explicitly. - try: - os.chmod(str(env_path), 0o600) - except OSError: - pass - check_ok(f"Created empty {_DHH}/.env") - check_info("Run 'hermes setup' to configure API keys") - fixed_count += 1 - else: - check_info("Run 'hermes setup' to create one") - issues.append("Run 'hermes setup' to create .env") - - # Check ~/.hermes/config.yaml (primary) or project cli-config.yaml (fallback) - config_path = HERMES_HOME / 'config.yaml' - if config_path.exists(): - check_ok(f"{_DHH}/config.yaml exists") - - # Validate model.provider and model.default values - try: - import yaml as _yaml - cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} - model_section = cfg.get("model") or {} - provider_raw = (model_section.get("provider") or "").strip() - provider = provider_raw.lower() - default_model = (model_section.get("default") or model_section.get("model") or "").strip() - - known_providers: set = set() - try: - from hermes_cli.auth import ( - PROVIDER_REGISTRY, - resolve_provider as _resolve_auth_provider, - ) - known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"} - except Exception: - _resolve_auth_provider = None - pass - try: - from hermes_cli.config import get_compatible_custom_providers as _compatible_custom_providers - from hermes_cli.providers import ( - normalize_provider as _normalize_catalog_provider, - resolve_provider_full as _resolve_provider_full, - ) - except Exception: - _compatible_custom_providers = None - _normalize_catalog_provider = None - _resolve_provider_full = None - - custom_providers = [] - if _compatible_custom_providers is not None: - try: - custom_providers = _compatible_custom_providers(cfg) - except Exception: - custom_providers = [] - - user_providers = cfg.get("providers") - if isinstance(user_providers, dict): - known_providers.update(str(name).strip().lower() for name in user_providers if str(name).strip()) - for entry in custom_providers: - if not isinstance(entry, dict): - continue - name = str(entry.get("name") or "").strip() - if name: - known_providers.add("custom:" + name.lower().replace(" ", "-")) - - valid_provider_ids = set(known_providers) - provider_ids_to_accept = {provider} if provider else set() - if _normalize_catalog_provider is not None: - for known_provider in known_providers: - try: - valid_provider_ids.add(_normalize_catalog_provider(known_provider)) - except Exception: - continue - - runtime_provider = provider - if ( - provider - and _resolve_auth_provider is not None - and provider not in {"auto", "custom"} - ): - try: - runtime_provider = _resolve_auth_provider(provider) - provider_ids_to_accept.add(runtime_provider) - except Exception: - runtime_provider = provider - - catalog_provider = provider - if ( - provider - and _resolve_provider_full is not None - and provider not in {"auto", "custom"} - ): - provider_def = _resolve_provider_full(provider, user_providers, custom_providers) - catalog_provider = provider_def.id if provider_def is not None else None - if catalog_provider is not None: - provider_ids_to_accept.add(catalog_provider) - - if provider and provider != "auto": - if catalog_provider is None or ( - known_providers - and not (provider_ids_to_accept & valid_provider_ids) - ): - known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" - _fail_and_issue( - f"model.provider '{provider_raw}' is not a recognised provider", - f"(known: {known_list})", - ( - f"model.provider '{provider_raw}' is unknown. " - f"Valid providers: {known_list}. " - f"Fix: run 'hermes config set model.provider '" - ), - issues, - ) - - # Warn if model is set to a provider-prefixed name on a provider that doesn't use them. - # Vendor/model slugs are valid on aggregator-style providers and on any custom - # provider — bare "custom" or a named "custom:" that fronts an OpenAI-compatible - # aggregator (e.g. custom:hpc-ai serving deepseek/deepseek-v4-flash) requires the prefix. - provider_for_policy = runtime_provider or catalog_provider - provider_policy_id = str(provider_for_policy or "").strip().lower() - providers_accepting_vendor_slugs = { - "openrouter", - "auto", - "kilocode", - "opencode-zen", - "huggingface", - "lmstudio", - "nous", - } - provider_accepts_vendor_slug = ( - provider_policy_id in providers_accepting_vendor_slugs - or provider_policy_id == "custom" - or provider_policy_id.startswith("custom:") - ) - if ( - default_model - and "/" in default_model - and provider_policy_id - and not provider_accepts_vendor_slug - ): - check_warn( - f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'", - "(vendor-prefixed slugs belong to aggregators like openrouter)", - ) - issues.append( - f"model.default '{default_model}' is vendor-prefixed but model.provider is '{provider_raw}'. " - "Either set model.provider to 'openrouter', or drop the vendor prefix." - ) - - # Check credentials for the configured provider. - # Limit to API-key providers in PROVIDER_REGISTRY — other provider - # types (OAuth, SDK, anthropic/custom/auto) have their own env-var - # checks elsewhere in doctor, and get_auth_status() returns a bare - # {logged_in: False} for anything it doesn't explicitly dispatch, - # which would produce false positives. - if runtime_provider and runtime_provider not in ("auto", "custom"): - try: - if runtime_provider == "openrouter": - from hermes_cli.config import get_env_value - - configured = bool( - str(get_env_value("OPENROUTER_API_KEY") or "").strip() - or str(get_env_value("OPENAI_API_KEY") or "").strip() - ) - else: - from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status - - pconfig = PROVIDER_REGISTRY.get(runtime_provider) - configured = True - if pconfig and getattr(pconfig, "auth_type", "") == "api_key": - status = get_auth_status(runtime_provider) or {} - configured = bool( - status.get("configured") - or status.get("logged_in") - or status.get("api_key") - ) - if not configured: - _fail_and_issue( - f"model.provider '{runtime_provider}' is set but no API key is configured", - "(check ~/.hermes/.env or run 'hermes setup')", - ( - f"No credentials found for provider '{runtime_provider}'. " - f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, " - f"or switch providers with 'hermes config set model.provider '" - ), - issues, - ) - except Exception: - pass - - except Exception as e: - check_warn("Could not validate model/provider config", f"({e})") - else: - fallback_config = PROJECT_ROOT / 'cli-config.yaml' - if fallback_config.exists(): - check_ok("cli-config.yaml exists (in project directory)") - else: - if should_fix: - config_path.parent.mkdir(parents=True, exist_ok=True) - example_config = PROJECT_ROOT / 'cli-config.yaml.example' - if example_config.exists(): - shutil.copy2(str(example_config), str(config_path)) - check_ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") - else: - from hermes_cli.config import DEFAULT_CONFIG, save_config - save_config(DEFAULT_CONFIG) - check_ok(f"Created {_DHH}/config.yaml from defaults") - fixed_count += 1 - else: - check_warn("config.yaml not found", "(using defaults)") - - # Check config version and stale keys - config_path = HERMES_HOME / 'config.yaml' - if config_path.exists(): - try: - from hermes_cli.config import check_config_version, migrate_config - current_ver, latest_ver = check_config_version() - if current_ver < latest_ver: - check_warn( - f"Config version outdated (v{current_ver} → v{latest_ver})", - "(new settings available)" - ) - if should_fix: - try: - migrate_config(interactive=False, quiet=False) - check_ok("Config migrated to latest version") - fixed_count += 1 - except Exception as mig_err: - check_warn(f"Auto-migration failed: {mig_err}") - issues.append("Run 'hermes setup' to migrate config") - else: - issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config") - else: - check_ok(f"Config version up to date (v{current_ver})") - except Exception: - pass - - # Detect stale root-level model keys (known bug source — PR #4329) - try: - import yaml - with open(config_path, encoding="utf-8") as f: - raw_config = yaml.safe_load(f) or {} - stale_root_keys = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)] - if stale_root_keys: - check_warn( - f"Stale root-level config keys: {', '.join(stale_root_keys)}", - "(should be under 'model:' section)" - ) - if should_fix: - # Coerce scalar/None ``model:`` into a dict before mutation — - # ``setdefault("model", {})`` would return an existing scalar - # and then ``model_section[k] = ...`` would raise TypeError. - raw_model = raw_config.get("model") - if isinstance(raw_model, dict): - model_section = raw_model - elif isinstance(raw_model, str) and raw_model.strip(): - model_section = {"default": raw_model.strip()} - raw_config["model"] = model_section - else: - model_section = {} - raw_config["model"] = model_section - for k in stale_root_keys: - if not model_section.get(k): - model_section[k] = raw_config.pop(k) - else: - raw_config.pop(k) - from utils import atomic_yaml_write - atomic_yaml_write(config_path, raw_config) - check_ok("Migrated stale root-level keys into model section") - fixed_count += 1 - else: - issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'") - except Exception: - pass - - # Detect stale HERMES_MAX_ITERATIONS ghost in .env shadowing - # agent.max_turns in config.yaml (issue #17534). The setup wizard - # used to dual-write the iteration budget to both stores; users who - # later edit only config.yaml are left with a .env ghost. The gateway - # bridge normally derives HERMES_MAX_ITERATIONS from agent.max_turns - # at startup, but if that bridge bails (any earlier config-parse - # error), the stale .env value silently wins and the agent runs at the - # wrong budget — e.g. config says 400 but the activity line reads N/90. - # Read the .env FILE directly (load_env), not get_env_value/os.environ, - # which the startup bridge may already have overridden. - try: - import yaml - from hermes_cli.config import load_env, remove_env_value - with open(config_path, encoding="utf-8") as f: - raw_config = yaml.safe_load(f) or {} - agent_cfg = raw_config.get("agent") - cfg_max_turns = ( - agent_cfg.get("max_turns") - if isinstance(agent_cfg, dict) - else None - ) - # Legacy root-level key counts too. - if cfg_max_turns is None: - cfg_max_turns = raw_config.get("max_turns") - env_ghost = load_env().get("HERMES_MAX_ITERATIONS") - drift = ( - cfg_max_turns is not None - and env_ghost is not None - and str(cfg_max_turns).strip() != str(env_ghost).strip() - ) - if drift: - check_warn( - f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows " - f"agent.max_turns={cfg_max_turns} in config.yaml", - "(stale ghost from an earlier `hermes setup` run)", - ) - if should_fix: - if remove_env_value("HERMES_MAX_ITERATIONS"): - check_ok( - "Removed stale HERMES_MAX_ITERATIONS from .env " - f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)" - ) - fixed_count += 1 - else: - check_warn("Could not remove HERMES_MAX_ITERATIONS from .env") - manual_issues.append( - "Manually delete the HERMES_MAX_ITERATIONS line from " - f"{_DHH}/.env — config.yaml agent.max_turns is authoritative." - ) - else: - issues.append( - "Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — " - "run 'hermes doctor --fix'" - ) - except Exception: - pass - - # Validate config structure (catches malformed custom_providers, etc.) - try: - from hermes_cli.config import validate_config_structure - config_issues = validate_config_structure() - if config_issues: - _section("Config Structure") - for ci in config_issues: - if ci.severity == "error": - check_fail(ci.message) - else: - check_warn(ci.message) - # Show the hint indented - for hint_line in ci.hint.splitlines(): - check_info(hint_line) - issues.append(ci.message) - except Exception: - pass - - _section("xAI Model Retirement (May 15, 2026)") - - try: - from hermes_cli.config import load_config - from hermes_cli.xai_retirement import ( - MIGRATION_GUIDE_URL, - find_retired_xai_refs, - format_issue, - ) - - _xai_cfg = load_config() - retired_refs = find_retired_xai_refs(_xai_cfg) - if not retired_refs: - check_ok("No retired xAI models in config") - else: - for ref in retired_refs: - check_warn(format_issue(ref)) - check_info(f"Migration guide: {MIGRATION_GUIDE_URL}") - manual_issues.append( - f"Update {len(retired_refs)} retired xAI model reference(s) " - f"in config.yaml — see {MIGRATION_GUIDE_URL}" - ) - except Exception as _xai_check_err: - check_warn("xAI retirement check skipped", f"({_xai_check_err})") - - _section("Auth Providers") - - try: - from hermes_cli.auth import ( - get_nous_auth_status, - get_codex_auth_status, - get_gemini_oauth_auth_status, - get_minimax_oauth_auth_status, - ) - - nous_status = get_nous_auth_status() - if nous_status.get("logged_in"): - check_ok("Nous Portal auth", "(logged in)") - else: - check_warn("Nous Portal auth", "(not logged in)") - - codex_status = get_codex_auth_status() - if codex_status.get("logged_in"): - check_ok("OpenAI Codex auth", "(logged in)") - else: - check_warn("OpenAI Codex auth", "(not logged in)") - if codex_status.get("error"): - check_info(codex_status["error"]) - # Native OAuth uses Hermes' own device-code flow — the Codex CLI is - # only needed to import existing tokens from ~/.codex/auth.json. - # Attach the hint to the Codex auth row so it doesn't read as - # remediation for whichever provider happens to print next (#27975). - if not _safe_which("codex"): - check_info( - "codex CLI not installed " - "(optional — only required to import tokens " - "from an existing Codex CLI login)" - ) - - gemini_status = get_gemini_oauth_auth_status() - if gemini_status.get("logged_in"): - email = gemini_status.get("email") or "" - project = gemini_status.get("project_id") or "" - pieces = [] - if email: - pieces.append(email) - if project: - pieces.append(f"project={project}") - suffix = f" ({', '.join(pieces)})" if pieces else "" - check_ok("Google Gemini OAuth", f"(logged in{suffix})") - else: - check_warn("Google Gemini OAuth", "(not logged in)") - - minimax_status = get_minimax_oauth_auth_status() - if minimax_status.get("logged_in"): - region = minimax_status.get("region", "global") - check_ok("MiniMax OAuth", f"(logged in, region={region})") - else: - check_warn("MiniMax OAuth", "(not logged in)") - except Exception as e: - check_warn("Auth provider status", f"(could not check: {e})") - - # xAI OAuth — separate try/except so an import failure here cannot - # disrupt the already-printed Nous/Codex/Gemini/MiniMax rows above. - try: - from hermes_cli.auth import get_xai_oauth_auth_status - xai_oauth_status = get_xai_oauth_auth_status() or {} - if xai_oauth_status.get("logged_in"): - check_ok("xAI OAuth", "(logged in)") - else: - check_warn("xAI OAuth", "(not logged in)") - if xai_oauth_status.get("error"): - check_info(xai_oauth_status["error"]) - except Exception: - pass - - _section("Directory Structure") - hermes_home = HERMES_HOME - if hermes_home.exists(): - check_ok(f"{_DHH} directory exists") - elif should_fix: - hermes_home.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH} directory") - fixed_count += 1 - else: - check_warn(f"{_DHH} not found", "(will be created on first use)") - - # Check expected subdirectories - expected_subdirs = ["cron", "sessions", "logs", "skills", "memories"] - for subdir_name in expected_subdirs: - subdir_path = hermes_home / subdir_name - if subdir_path.exists(): - check_ok(f"{_DHH}/{subdir_name}/ exists") - elif should_fix: - subdir_path.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH}/{subdir_name}/") - fixed_count += 1 - else: - check_warn(f"{_DHH}/{subdir_name}/ not found", "(will be created on first use)") - - # Check for SOUL.md persona file - soul_path = hermes_home / "SOUL.md" - if soul_path.exists(): - content = soul_path.read_text(encoding="utf-8").strip() - # Check if it's just the template comments (no real content) - lines = [l for l in content.splitlines() if l.strip() and not l.strip().startswith(("", "#"))] - if lines: - check_ok(f"{_DHH}/SOUL.md exists (persona configured)") - else: - check_info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality") - else: - check_warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)") - if should_fix: - soul_path.parent.mkdir(parents=True, exist_ok=True) - soul_path.write_text( - "# Hermes Agent Persona\n\n" - "\n\n" - "You are Hermes, a helpful AI assistant.\n", - encoding="utf-8", - ) - check_ok(f"Created {_DHH}/SOUL.md with basic template") - fixed_count += 1 - - # Check memory directory - memories_dir = hermes_home / "memories" - if memories_dir.exists(): - check_ok(f"{_DHH}/memories/ directory exists") - memory_file = memories_dir / "MEMORY.md" - user_file = memories_dir / "USER.md" - if memory_file.exists(): - size = len(memory_file.read_text(encoding="utf-8").strip()) - check_ok(f"MEMORY.md exists ({size} chars)") - else: - check_info("MEMORY.md not created yet (will be created when the agent first writes a memory)") - if user_file.exists(): - size = len(user_file.read_text(encoding="utf-8").strip()) - check_ok(f"USER.md exists ({size} chars)") - else: - check_info("USER.md not created yet (will be created when the agent first writes a memory)") - else: - check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)") - if should_fix: - memories_dir.mkdir(parents=True, exist_ok=True) - check_ok(f"Created {_DHH}/memories/") - fixed_count += 1 - - # Check SQLite session store - state_db_path = hermes_home / "state.db" - if state_db_path.exists(): - try: - import sqlite3 - conn = sqlite3.connect(str(state_db_path)) - cursor = conn.execute("SELECT COUNT(*) FROM sessions") - count = cursor.fetchone()[0] - conn.close() - check_ok(f"{_DHH}/state.db exists ({count} sessions)") - except Exception as e: - from hermes_state import is_malformed_db_error, repair_state_db_schema - - if is_malformed_db_error(e): - # sqlite_master itself is malformed (e.g. duplicate - # messages_fts) — every statement fails before it runs, so - # this is NOT a plain FTS-index rebuild. Repair sqlite_master - # in place (backup first; sessions/messages preserved). - check_warn( - f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)", - f"({e})", - ) - if should_fix: - report = repair_state_db_schema(state_db_path) - if report.get("repaired"): - try: - conn = sqlite3.connect(str(state_db_path)) - count = conn.execute( - "SELECT COUNT(*) FROM sessions" - ).fetchone()[0] - conn.close() - except Exception: - count = "?" - backup_name = ( - Path(report["backup_path"]).name - if report.get("backup_path") else "n/a" - ) - check_ok( - f"Repaired state.db schema ({count} sessions recovered)", - f"(strategy: {report.get('strategy')}; backup: {backup_name})", - ) - fixed_count += 1 - else: - check_warn( - "state.db schema repair did not recover automatically", - f"({report.get('error')}; backup: {report.get('backup_path')})", - ) - issues.append( - "state.db schema malformed and auto-repair failed — " - "restore from the backup copy beside state.db" - ) - else: - issues.append( - "state.db schema malformed — run 'hermes doctor --fix' " - "(or 'hermes sessions repair') to recover hidden sessions" - ) - else: - check_warn(f"{_DHH}/state.db exists but has issues: {e}") - else: - check_info(f"{_DHH}/state.db not created yet (will be created on first session)") - - # Check WAL file size (unbounded growth indicates missed checkpoints) - wal_path = hermes_home / "state.db-wal" - if wal_path.exists(): - try: - wal_size = wal_path.stat().st_size - if wal_size > 50 * 1024 * 1024: # 50 MB - check_warn( - f"WAL file is large ({wal_size // (1024*1024)} MB)", - "(may indicate missed checkpoints)" - ) - if should_fix: - import sqlite3 - conn = sqlite3.connect(str(state_db_path)) - conn.execute("PRAGMA wal_checkpoint(PASSIVE)") - conn.close() - new_size = wal_path.stat().st_size if wal_path.exists() else 0 - check_ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)") - fixed_count += 1 - else: - issues.append("Large WAL file — run 'hermes doctor --fix' to checkpoint") - elif wal_size > 10 * 1024 * 1024: # 10 MB - check_info(f"WAL file is {wal_size // (1024*1024)} MB (normal for active sessions)") - except Exception: - pass - - _check_gateway_service_linger(issues) - _check_s6_supervision(issues) - - if sys.platform != "win32": - _section("Command Installation") - # Determine the venv entry point location - _venv_bin = None - for _venv_name in ("venv", ".venv"): - _candidate = PROJECT_ROOT / _venv_name / "bin" / "hermes" - if _candidate.exists(): - _venv_bin = _candidate - break - - # Determine the expected command link directory (mirrors install.sh logic) - _prefix = os.environ.get("PREFIX", "") - _is_termux_env = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in _prefix - if _is_termux_env and _prefix: - _cmd_link_dir = Path(_prefix) / "bin" - _cmd_link_display = "$PREFIX/bin" - else: - _cmd_link_dir = Path.home() / ".local" / "bin" - _cmd_link_display = "~/.local/bin" - _cmd_link = _cmd_link_dir / "hermes" - - if _venv_bin is None: - check_warn( - "Venv entry point not found", - "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')" - ) - manual_issues.append( - f"Reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" - ) - else: - check_ok(f"Venv entry point exists ({_venv_bin.relative_to(PROJECT_ROOT)})") - - # Check the symlink at the command link location - if _cmd_link.is_symlink(): - _target = _cmd_link.resolve() - _expected = _venv_bin.resolve() - if _target == _expected: - check_ok(f"{_cmd_link_display}/hermes → correct target") - else: - check_warn( - f"{_cmd_link_display}/hermes points to wrong target", - f"(→ {_target}, expected → {_expected})" - ) - if should_fix: - _cmd_link.unlink() - _cmd_link.symlink_to(_venv_bin) - check_ok(f"Fixed symlink: {_cmd_link_display}/hermes → {_venv_bin}") - fixed_count += 1 - else: - issues.append(f"Broken symlink at {_cmd_link_display}/hermes — run 'hermes doctor --fix'") - elif _cmd_link.exists(): - # It's a regular file, not a symlink — possibly a wrapper script - check_ok(f"{_cmd_link_display}/hermes exists (non-symlink)") - else: - check_fail( - f"{_cmd_link_display}/hermes not found", - "(hermes command may not work outside the venv)" - ) - if should_fix: - _cmd_link_dir.mkdir(parents=True, exist_ok=True) - _cmd_link.symlink_to(_venv_bin) - check_ok(f"Created symlink: {_cmd_link_display}/hermes → {_venv_bin}") - fixed_count += 1 - - # Check if the link dir is on PATH - _path_dirs = os.environ.get("PATH", "").split(os.pathsep) - if str(_cmd_link_dir) not in _path_dirs: - check_warn( - f"{_cmd_link_display} is not on your PATH", - "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")" - ) - manual_issues.append(f"Add {_cmd_link_display} to your PATH") - else: - issues.append(f"Missing {_cmd_link_display}/hermes symlink — run 'hermes doctor --fix'") - - _section("External Tools") - # Git - if _safe_which("git"): - check_ok("git") - else: - check_warn("git not found", "(hermes update cannot work)") - - # ripgrep (optional, for faster file search) - if _safe_which("rg"): - check_ok("ripgrep (rg)", "(faster file search)") - else: - check_warn("ripgrep (rg) not found", "(file search uses grep fallback)") - check_info(f"Install for faster search: {_system_package_install_cmd('ripgrep')}") - - # Docker (optional) - terminal_env = os.getenv("TERMINAL_ENV", "local") - try: - from hermes_constants import is_container as _is_container - running_in_container = _is_container() - except Exception: - running_in_container = False - - if running_in_container: - # Inside our container the Docker terminal backend is not - # configured by default (Docker-in-Docker isn't set up); the - # local backend is the intended one. Skip the noisy "docker - # not found" warning. If the user has explicitly chosen - # TERMINAL_ENV=docker inside the container they likely mounted - # /var/run/docker.sock, so fall through to the normal check. - if terminal_env != "docker": - check_info( - "Running inside a container — using local terminal backend " - "(docker-in-docker is not configured by default)" - ) - # Skip to next section; Docker isn't relevant here. - terminal_env = "local" - if terminal_env == "docker": - if _safe_which("docker"): - # Check if docker daemon is running - try: - result = subprocess.run(["docker", "info"], capture_output=True, timeout=10) - except subprocess.TimeoutExpired: - result = None - if result is not None and result.returncode == 0: - check_ok("docker", "(daemon running)") - else: - _fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues) - else: - _fail_and_issue( - "docker not found", - "(required for TERMINAL_ENV=docker)", - "Install Docker or change TERMINAL_ENV", - issues, - ) - elif _safe_which("docker"): - check_ok("docker", "(optional)") - elif _is_termux(): - check_info("Docker backend is not available inside Termux (expected on Android)") - elif running_in_container: - pass # already explained above - else: - check_warn("docker not found", "(optional)") - - # SSH (if using ssh backend) - if terminal_env == "ssh": - ssh_host = os.getenv("TERMINAL_SSH_HOST") - if ssh_host: - ssh_user = os.getenv("TERMINAL_SSH_USER") - ssh_port = os.getenv("TERMINAL_SSH_PORT") - ssh_key = os.getenv("TERMINAL_SSH_KEY") - target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host - cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] - if ssh_port: - cmd += ["-p", ssh_port] - if ssh_key: - cmd += ["-i", os.path.expanduser(ssh_key)] - cmd += [target, "echo ok"] - # Try to connect - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=15 - ) - except subprocess.TimeoutExpired: - result = None - if result is not None and result.returncode == 0: - check_ok(f"SSH connection to {ssh_host}") - else: - _fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues) - else: - _fail_and_issue( - "TERMINAL_SSH_HOST not set", - "(required for TERMINAL_ENV=ssh)", - "Set TERMINAL_SSH_HOST in .env", - issues, - ) - - # Daytona (if using daytona backend) - if terminal_env == "daytona": - daytona_key = os.getenv("DAYTONA_API_KEY") - if daytona_key: - check_ok("Daytona API key", "(configured)") - else: - _fail_and_issue( - "DAYTONA_API_KEY not set", - "(required for TERMINAL_ENV=daytona)", - "Set DAYTONA_API_KEY environment variable", - issues, - ) - try: - from daytona import Daytona # noqa: F401 — SDK presence check - check_ok("daytona SDK", "(installed)") - except ImportError: - _fail_and_issue( - "daytona SDK not installed", - "(pip install daytona)", - "Install daytona SDK: pip install daytona", - issues, - ) - - # Node.js + agent-browser (for browser automation tools) - if _safe_which("node"): - check_ok("Node.js") - # Check if agent-browser is installed - agent_browser_path = PROJECT_ROOT / "node_modules" / "agent-browser" - agent_browser_ok = False - if agent_browser_path.exists(): - check_ok("agent-browser (Node.js)", "(browser automation)") - agent_browser_ok = True - elif shutil.which("agent-browser"): - check_ok("agent-browser", "(browser automation)") - agent_browser_ok = True - elif _is_termux(): - check_info("agent-browser is not installed (expected in the tested Termux path)") - check_info("Install it manually later with: npm install -g agent-browser && agent-browser install") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=True): - check_info(step) - else: - check_warn("agent-browser not installed", "(run: npm install)") - - # Chromium presence — the browser tools silently fail to register when - # agent-browser is found but no Playwright-managed Chromium is on disk - # (tools/browser_tool.py::check_browser_requirements filters them out - # before the agent ever sees them). Reuse the exact predicate it uses - # so the two checks cannot diverge. Skip on Termux (not a tested - # path). - if agent_browser_ok and not _is_termux(): - try: - # Lazy import: browser_tool is a ~150KB module we don't want - # to eagerly load in every `hermes doctor` invocation. - from tools.browser_tool import ( - _chromium_installed, - _is_camofox_mode, - _get_cloud_provider, - _get_cdp_override, - _using_lightpanda_engine, - ) - except Exception: - # If browser_tool can't even import, that's a separate bug - # surfaced elsewhere; don't crash doctor. - pass - else: - # Only warn about Chromium if the installed engine actually - # requires it: Camofox, CDP override, a cloud provider, or - # Lightpanda all bypass the local Chromium requirement. - skip_chromium_check = ( - _is_camofox_mode() - or bool(_get_cdp_override()) - or _get_cloud_provider() is not None - or _using_lightpanda_engine() - ) - if not skip_chromium_check: - if _chromium_installed(): - check_ok("Playwright Chromium", "(browser engine)") - else: - check_warn( - "Playwright Chromium not installed", - "(browser_* tools will be hidden from the agent)", - ) - if sys.platform == "win32": - check_info( - f"Install with: cd {PROJECT_ROOT} && " - "npx playwright install chromium" - ) - else: - check_info( - f"Install with: cd {PROJECT_ROOT} && " - "npx playwright install --with-deps chromium" - ) - elif _is_termux(): - check_info("Node.js not found (browser tools are optional in the tested Termux path)") - check_info("Install Node.js on Termux with: pkg install nodejs") - check_info("Termux browser setup:") - for step in _termux_browser_setup_steps(node_installed=False): - check_info(step) - else: - check_warn("Node.js not found", "(optional, needed for browser tools)") - - # npm audit for all Node.js packages - _npm_bin = _safe_which("npm") - if _npm_bin: - # Each entry: (cwd, label, extra_audit_args) - # PROJECT_ROOT is audited with --workspaces=false so that the apps/* - # glob (which pulls in Electron, node-pty, etc.) is never resolved - # for a routine security check. The web and ui-tui workspaces are - # audited separately via --workspace flags. See #38772. - npm_audit_targets = [ - (PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]), - (PROJECT_ROOT, "web workspace", ["--workspace", "web"]), - (PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]), - (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []), - ] - for npm_dir, label, audit_extra in npm_audit_targets: - # For workspace-scoped audits run from PROJECT_ROOT the - # node_modules check must use the workspace root; standalone dirs - # (whatsapp-bridge) check their own node_modules. - check_dir = PROJECT_ROOT if audit_extra else npm_dir - if not (check_dir / "node_modules").exists(): - continue - try: - # Use resolved absolute path so Windows can execute - # npm.cmd (CreateProcessW can't run bare .cmd names). - audit_result = subprocess.run( - [_npm_bin, "audit", "--json", *audit_extra], - cwd=str(npm_dir), - capture_output=True, text=True, timeout=30, - ) - import json as _json - audit_data = _json.loads(audit_result.stdout) if audit_result.stdout.strip() else {} - vuln_count = audit_data.get("metadata", {}).get("vulnerabilities", {}) - critical = vuln_count.get("critical", 0) - high = vuln_count.get("high", 0) - moderate = vuln_count.get("moderate", 0) - total = critical + high + moderate - # Determine a scoped fix command for the remediation hint. - if audit_extra and audit_extra[0] == "--workspace": - fix_scope = " ".join(audit_extra) - fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}" - elif audit_extra == ["--workspaces=false"]: - fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false" - else: - fix_cmd = f"cd {npm_dir} && npm audit fix" - if total == 0: - check_ok(f"{label} deps", "(no known vulnerabilities)") - elif critical > 0 or high > 0: - check_warn( - f"{label} deps", - f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})" - ) - issues.append( - f"{label} has {total} npm " - f"{'vulnerability' if total == 1 else 'vulnerabilities'}" - ) - else: - check_ok( - f"{label} deps", - f"({moderate} moderate " - f"{'vulnerability' if moderate == 1 else 'vulnerabilities'})", - ) - except Exception: - pass - - if _is_termux(): - check_info("Termux compatibility fallbacks:") - for note in _termux_install_all_fallback_notes(): - check_info(note) - - _section("API Connectivity") - # Refactor: every connectivity probe below is HTTP-bound and fully - # independent. Running them in series spent ~5s wall on a typical - # workstation (2s of that was boto3's IMDS lookup for AWS credentials, - # which times out unless you're actually on EC2). Threading them with - # a small executor pool collapses the section to roughly the slowest - # single probe — about 2s — without changing the output format. - # - # Each ``_probe_*`` helper is a pure function: takes its inputs, - # makes one HTTP/SDK call, returns a ``_ConnectivityResult`` carrying - # the line(s) to print and any issue strings to append. No globals, - # no shared mutable state, no printing inside the workers. - import concurrent.futures as _futures - from collections import namedtuple as _namedtuple - - _ConnectivityResult = _namedtuple( - "_ConnectivityResult", ["label", "lines", "issues"] - ) - _probes: list = [] # list of (label, callable) submitted in display order - - def _probe_openrouter() -> _ConnectivityResult: - key = os.getenv("OPENROUTER_API_KEY") - if not key: - return _ConnectivityResult( - "OpenRouter API", - [(color("⚠", Colors.YELLOW), "OpenRouter API", - color("(not configured)", Colors.DIM))], - [], - ) - try: - import httpx - r = httpx.get( - OPENROUTER_MODELS_URL, - headers={"Authorization": f"Bearer {key}"}, - timeout=10, - ) - if r.status_code == 200: - return _ConnectivityResult( - "OpenRouter API", - [(color("✓", Colors.GREEN), "OpenRouter API", "")], - [], - ) - if r.status_code == 401: - return _ConnectivityResult( - "OpenRouter API", - [(color("✗", Colors.RED), "OpenRouter API", - color("(invalid API key)", Colors.DIM))], - ["Check OPENROUTER_API_KEY in .env"], - ) - if r.status_code == 402: - return _ConnectivityResult( - "OpenRouter API", - [(color("✗", Colors.RED), "OpenRouter API", - color("(out of credits — payment required)", Colors.DIM))], - ["OpenRouter account has insufficient credits. " - "Fix: run 'hermes config set model.provider ' " - "to switch providers, or fund your OpenRouter account " - "at https://openrouter.ai/settings/credits"], - ) - if r.status_code == 429: - return _ConnectivityResult( - "OpenRouter API", - [(color("✗", Colors.RED), "OpenRouter API", - color("(rate limited)", Colors.DIM))], - ["OpenRouter rate limit hit — consider switching to " - "a different provider or waiting"], - ) - return _ConnectivityResult( - "OpenRouter API", - [(color("✗", Colors.RED), "OpenRouter API", - color(f"(HTTP {r.status_code})", Colors.DIM))], - [], - ) - except Exception as e: - return _ConnectivityResult( - "OpenRouter API", - [(color("✗", Colors.RED), "OpenRouter API", - color(f"({e})", Colors.DIM))], - ["Check network connectivity"], - ) - - def _probe_anthropic() -> _ConnectivityResult: - from hermes_cli.auth import get_anthropic_key - key = get_anthropic_key() - if not key: - return _ConnectivityResult("Anthropic API", [], []) - try: - import httpx - from agent.anthropic_adapter import ( - _is_oauth_token, - _COMMON_BETAS, - _OAUTH_ONLY_BETAS, - _CONTEXT_1M_BETA, - ) - headers = {"anthropic-version": "2023-06-01"} - is_oauth = _is_oauth_token(key) - if is_oauth: - headers["Authorization"] = f"Bearer {key}" - headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) - else: - headers["x-api-key"] = key - r = httpx.get( - "https://api.anthropic.com/v1/models", - headers=headers, timeout=10, - ) - # Reactive recovery: OAuth subscriptions without 1M context reject the - # request with 400 "long context beta is not yet available for this - # subscription". Retry once with that beta stripped so the doctor - # check doesn't falsely report Anthropic as unreachable. - if ( - is_oauth - and r.status_code == 400 - and "long context beta" in r.text.lower() - and "not yet available" in r.text.lower() - ): - headers["anthropic-beta"] = ",".join( - [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] - + list(_OAUTH_ONLY_BETAS) - ) - r = httpx.get( - "https://api.anthropic.com/v1/models", - headers=headers, timeout=10, - ) - if r.status_code == 200: - return _ConnectivityResult( - "Anthropic API", - [(color("✓", Colors.GREEN), "Anthropic API", "")], - [], - ) - if r.status_code == 401: - return _ConnectivityResult( - "Anthropic API", - [(color("✗", Colors.RED), "Anthropic API", - color("(invalid API key)", Colors.DIM))], - [], - ) - return _ConnectivityResult( - "Anthropic API", - [(color("⚠", Colors.YELLOW), "Anthropic API", - color("(couldn't verify)", Colors.DIM))], - [], - ) - except Exception as e: - return _ConnectivityResult( - "Anthropic API", - [(color("⚠", Colors.YELLOW), "Anthropic API", - color(f"({e})", Colors.DIM))], - [], - ) - - def _probe_apikey_provider(pname, env_vars, default_url, base_env, - supports_health_check) -> _ConnectivityResult: - key = "" - for ev in env_vars: - key = os.getenv(ev, "") - if key: - break - if not key: - return _ConnectivityResult(pname, [], []) - label = pname.ljust(20) - if not supports_health_check: - return _ConnectivityResult( - pname, - [(color("✓", Colors.GREEN), label, - color("(key configured)", Colors.DIM))], - [], - ) - try: - import httpx - base = os.getenv(base_env, "") if base_env else "" - # Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1 - # (OpenAI-compat surface, which exposes /models for health check). - if not base and key.startswith("sk-kimi-"): - base = "https://api.kimi.com/coding/v1" - # Anthropic-compat endpoints (/anthropic, api.kimi.com/coding - # with no /v1) don't support /models. Rewrite to OpenAI-compat - # /v1 surface for health checks. - if base and base.rstrip("/").endswith("/anthropic"): - from agent.auxiliary_client import _to_openai_base_url - base = _to_openai_base_url(base) - if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"): - base = base.rstrip("/") + "/v1" - url = (base.rstrip("/") + "/models") if base else default_url - headers = { - "Authorization": f"Bearer {key}", - "User-Agent": _HERMES_USER_AGENT, - } - if base_url_host_matches(base, "api.kimi.com"): - headers["User-Agent"] = "claude-code/0.1.0" - # Google's Generative Language API (generativelanguage.googleapis.com) - # rejects ``Authorization: Bearer `` with 401 - # ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is reserved for - # OAuth 2 access tokens, not plain API keys. Plain keys use - # ``x-goog-api-key`` (or ``?key=``). Without this, a perfectly valid - # GOOGLE_API_KEY/GEMINI_API_KEY always shows red in ``hermes doctor``. - if url and base_url_host_matches(url, "generativelanguage.googleapis.com"): - headers.pop("Authorization", None) - headers["x-goog-api-key"] = key - r = httpx.get(url, headers=headers, timeout=10) - if ( - pname == "Alibaba/DashScope" - and not base - and r.status_code == 401 - ): - r = httpx.get( - "https://dashscope.aliyuncs.com/compatible-mode/v1/models", - headers=headers, timeout=10, - ) - if r.status_code == 200: - return _ConnectivityResult( - pname, - [(color("✓", Colors.GREEN), label, "")], - [], - ) - if r.status_code == 401: - return _ConnectivityResult( - pname, - [(color("✗", Colors.RED), label, - color("(invalid API key)", Colors.DIM))], - [f"Check {env_vars[0]} in .env"], - ) - return _ConnectivityResult( - pname, - [(color("⚠", Colors.YELLOW), label, - color(f"(HTTP {r.status_code})", Colors.DIM))], - [], - ) - except Exception as e: - return _ConnectivityResult( - pname, - [(color("⚠", Colors.YELLOW), label, - color(f"({e})", Colors.DIM))], - [], - ) - - def _probe_bedrock() -> _ConnectivityResult: - try: - from agent.bedrock_adapter import ( - has_aws_credentials, - resolve_aws_auth_env_var, - resolve_bedrock_region, - ) - except ImportError: - return _ConnectivityResult("AWS Bedrock", [], []) - if not has_aws_credentials(): - return _ConnectivityResult("AWS Bedrock", [], []) - auth_var = resolve_aws_auth_env_var() - region = resolve_bedrock_region() - label = "AWS Bedrock".ljust(20) - try: - import boto3 - from botocore.config import Config as _BotoConfig - # Trim retries on the actual Bedrock API call so a transient - # failure doesn't pad the doctor run by 30+ seconds. - cfg = _BotoConfig( - connect_timeout=5, - read_timeout=10, - retries={"max_attempts": 1}, - ) - client = boto3.client("bedrock", region_name=region, config=cfg) - resp = client.list_foundation_models() - n = len(resp.get("modelSummaries", [])) - return _ConnectivityResult( - "AWS Bedrock", - [(color("✓", Colors.GREEN), label, - color(f"({auth_var}, {region}, {n} models)", Colors.DIM))], - [], - ) - except ImportError: - return _ConnectivityResult( - "AWS Bedrock", - [(color("⚠", Colors.YELLOW), label, - color(f"(boto3 not installed — uv pip install boto3)", - Colors.DIM))], - [f"Install boto3 for Bedrock: uv pip install boto3"], - ) - except Exception as e: - err_name = type(e).__name__ - return _ConnectivityResult( - "AWS Bedrock", - [(color("⚠", Colors.YELLOW), label, - color(f"({err_name}: {e})", Colors.DIM))], - [f"AWS Bedrock: {err_name} — check IAM permissions for " - f"bedrock:ListFoundationModels"], - ) - - def _probe_azure_entra() -> _ConnectivityResult: - """Probe Azure Foundry Entra ID auth, parallel to ``_probe_bedrock``. - - Skipped unless the active config has ``model.provider: - azure-foundry`` AND ``model.auth_mode: entra_id`` — we don't probe - the token-service / CLI chain for users on plain API-key Azure. - - Bounded by a 10s timeout (via - :func:`agent.azure_identity_adapter.describe_active_credential`) - so a slow token service can't pad the doctor run. - """ - label = "Azure Foundry (Entra ID)".ljust(28) - try: - from hermes_cli.config import load_config - cfg = load_config() - model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} - if not isinstance(model_cfg, dict): - return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) - cfg_provider = str(model_cfg.get("provider") or "").strip().lower() - auth_mode = str(model_cfg.get("auth_mode") or "").strip().lower() - if cfg_provider != "azure-foundry" or auth_mode != "entra_id": - return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) - except Exception: - return _ConnectivityResult("Azure Foundry (Entra ID)", [], []) - - try: - from agent.azure_identity_adapter import ( - EntraIdentityConfig, - SCOPE_AI_AZURE_DEFAULT, - describe_active_credential, - has_azure_identity_installed, - ) - except Exception as exc: - return _ConnectivityResult( - "Azure Foundry (Entra ID)", - [(color("⚠", Colors.YELLOW), label, - color(f"(adapter import failed: {exc})", Colors.DIM))], - [f"Azure Foundry adapter import failed: {exc}"], - ) - - if not has_azure_identity_installed(): - return _ConnectivityResult( - "Azure Foundry (Entra ID)", - [(color("⚠", Colors.YELLOW), label, - color("(azure-identity not installed)", Colors.DIM))], - [f"Install azure-identity: uv pip install azure-identity"], - ) - - base_url = str(model_cfg.get("base_url") or "").strip() - entra_cfg = model_cfg.get("entra") or {} - if not isinstance(entra_cfg, dict): - entra_cfg = {} - scope = ( - str(entra_cfg.get("scope") or "").strip() - or SCOPE_AI_AZURE_DEFAULT - ) - config = EntraIdentityConfig( - scope=scope, - ) - info = describe_active_credential(config=config, timeout_seconds=10.0) - if info.get("ok"): - env_sources = info.get("env_sources") or [] - tag = ", ".join(env_sources) if env_sources else "default credential chain" - return _ConnectivityResult( - "Azure Foundry (Entra ID)", - [(color("✓", Colors.GREEN), label, - color(f"({tag}, scope={scope})", Colors.DIM))], - [], - ) - err = info.get("error") or "credential chain exhausted" - hint = info.get("hint") or ( - "Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/" - "AZURE_CLIENT_SECRET, or attach a managed identity to this VM." - ) - return _ConnectivityResult( - "Azure Foundry (Entra ID)", - [(color("⚠", Colors.YELLOW), label, - color(f"({err})", Colors.DIM))], - [f"Azure Foundry Entra: {err}. {hint}"], - ) - - # Build the probe submission list in display order - _probes.append(("OpenRouter API", _probe_openrouter)) - _probes.append(("Anthropic API", _probe_anthropic)) - - global _APIKEY_PROVIDERS_CACHE - if _APIKEY_PROVIDERS_CACHE is None: - _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list() - for _entry in _APIKEY_PROVIDERS_CACHE: - _pname, _env_vars, _default_url, _base_env, _supports = _entry - # Capture loop vars by binding default args — without this, all closures - # would share the final iteration's values and every probe would hit - # the last provider's URL. - _probes.append((_pname, lambda p=_pname, e=_env_vars, u=_default_url, - b=_base_env, s=_supports: - _probe_apikey_provider(p, e, u, b, s))) - - _probes.append(("AWS Bedrock", _probe_bedrock)) - _probes.append(("Azure Foundry (Entra ID)", _probe_azure_entra)) - - # Print a single status line so users see something happening, then - # fan out. ``\r`` clears it once the first real result line lands. - print(f" {color(f'Running {len(_probes)} connectivity checks in parallel…', Colors.DIM)}", - end="", flush=True) - - # Disable boto3's EC2 instance-metadata-service probe for the duration - # of the parallel block. boto's default credential chain tries - # 169.254.169.254 with a multi-second timeout when we're not on EC2, - # which dominated the section's wall time before this fix - # (~2s on a developer laptop, even with the rest parallelized). - # Set on the parent thread before submitting work so the env-var - # mutation never races with another worker. has_aws_credentials() in - # the bedrock probe already gates on real env-var creds, so IMDS is - # never the legitimate source for `hermes doctor`. - _imds_prev = os.environ.get("AWS_EC2_METADATA_DISABLED") - os.environ["AWS_EC2_METADATA_DISABLED"] = "true" - try: - # 8 workers is plenty — each probe is a single HTTP call plus a TLS - # handshake. More than that wastes thread-startup cost and risks - # noisy output if anything ever printed from inside a worker. - with _futures.ThreadPoolExecutor(max_workers=8, - thread_name_prefix="doctor-probe") as _ex: - _futures_in_order = [_ex.submit(_fn) for _, _fn in _probes] - _results = [_f.result() for _f in _futures_in_order] - finally: - if _imds_prev is None: - os.environ.pop("AWS_EC2_METADATA_DISABLED", None) - else: - os.environ["AWS_EC2_METADATA_DISABLED"] = _imds_prev - - # Clear the "Running …" line and print all results in submission order. - print("\r" + " " * 70 + "\r", end="") - for _r in _results: - for _glyph, _label, _detail in _r.lines: - if _detail: - print(f" {_glyph} {_label} {_detail}") - else: - print(f" {_glyph} {_label}") - _issues_to_add = list(_r.issues) - if _issues_to_add and _has_healthy_oauth_fallback_for_apikey_provider(_r.label): - _issues_to_add = [] - for _issue in _issues_to_add: - issues.append(_issue) - - _section("Tool Availability") - try: - # Add project root to path for imports - sys.path.insert(0, str(PROJECT_ROOT)) - from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS - - available, unavailable = check_tool_availability() - available, unavailable = _apply_doctor_tool_availability_overrides(available, unavailable) - - for tid in available: - info = TOOLSET_REQUIREMENTS.get(tid, {}) - check_ok(info.get("name", tid), _doctor_tool_availability_detail(tid)) - - for item in unavailable: - env_vars = item.get("missing_vars") or item.get("env_vars") or [] - if env_vars: - vars_str = ", ".join(env_vars) - check_warn(item["name"], f"(missing {vars_str})") - else: - check_warn(item["name"], "(system dependency not met)") - - # Count disabled tools with API key requirements - api_disabled = [u for u in unavailable if (u.get("missing_vars") or u.get("env_vars"))] - if api_disabled: - issues.append("Run 'hermes setup' to configure missing API keys for full tool access") - except Exception as e: - check_warn("Could not check tool availability", f"({e})") - - _section("Skills Hub") - hub_dir = HERMES_HOME / "skills" / ".hub" - if hub_dir.exists(): - check_ok("Skills Hub directory exists") - lock_file = hub_dir / "lock.json" - if lock_file.exists(): - try: - import json - lock_data = json.loads(lock_file.read_text()) - count = len(lock_data.get("installed", {})) - check_ok(f"Lock file OK ({count} hub-installed skill(s))") - except Exception: - check_warn("Lock file", "(corrupted or unreadable)") - quarantine = hub_dir / "quarantine" - q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0 - if q_count > 0: - check_warn(f"{q_count} skill(s) in quarantine", "(pending review)") - else: - check_warn("Skills Hub directory not initialized", "(run: hermes skills list)") - - from hermes_cli.config import get_env_value - - def _gh_authenticated() -> bool: - """Check if gh CLI is authenticated via token file or device flow.""" - try: - result = subprocess.run( - ["gh", "auth", "status", "--json", "authenticated"], - capture_output=True, timeout=10, - ) - return result.returncode == 0 - except (FileNotFoundError, subprocess.TimeoutExpired): - return False - - github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") - if github_token: - check_ok("GitHub token configured (authenticated API access)") - elif _gh_authenticated(): - check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)") - else: - check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") - - _section("Memory Provider") - _active_memory_provider = "" - try: - import yaml as _yaml - _mem_cfg_path = HERMES_HOME / "config.yaml" - if _mem_cfg_path.exists(): - with open(_mem_cfg_path, encoding="utf-8") as _f: - _raw_cfg = _yaml.safe_load(_f) or {} - _active_memory_provider = (_raw_cfg.get("memory") or {}).get("provider", "") - except Exception: - pass - - if not _active_memory_provider: - check_ok("Built-in memory active", "(no external provider configured — this is fine)") - elif _active_memory_provider == "honcho": - try: - from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path - hcfg = HonchoClientConfig.from_global_config() - _honcho_cfg_path = resolve_config_path() - - if not _honcho_cfg_path.exists(): - # Config file missing — but env var fallback may have resolved it. - # Only warn if the config didn't actually resolve from env vars. - if hcfg.api_key or hcfg.base_url: - check_ok( - "Honcho configured via environment variables", - f"config file {_honcho_cfg_path} not found, using HONCHO_API_KEY env var", - ) - else: - check_warn("Honcho config not found", "run: hermes memory setup") - elif not hcfg.enabled: - check_info(f"Honcho disabled (set enabled: true in {_honcho_cfg_path} to activate)") - elif not (hcfg.api_key or hcfg.base_url): - _fail_and_issue( - "Honcho API key or base URL not set", - "run: hermes memory setup", - "No Honcho API key — run 'hermes memory setup'", - issues, - ) - else: - from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client - reset_honcho_client() - try: - get_honcho_client(hcfg) - check_ok( - "Honcho connected", - f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}", - ) - except Exception as _e: - _fail_and_issue("Honcho connection failed", str(_e), f"Honcho unreachable: {_e}", issues) - except ImportError: - _fail_and_issue( - "honcho-ai not installed", - "pip install honcho-ai", - "Honcho is set as memory provider but honcho-ai is not installed", - issues, - ) - except Exception as _e: - check_warn("Honcho check failed", str(_e)) - elif _active_memory_provider == "mem0": - try: - from plugins.memory.mem0 import _load_config as _load_mem0_config - mem0_cfg = _load_mem0_config() - mem0_key = mem0_cfg.get("api_key", "") - if mem0_key: - check_ok("Mem0 API key configured") - check_info(f"user_id={mem0_cfg.get('user_id', '?')} agent_id={mem0_cfg.get('agent_id', '?')}") - else: - _fail_and_issue( - "Mem0 API key not set", - "(set MEM0_API_KEY in .env or run hermes memory setup)", - "Mem0 is set as memory provider but API key is missing", - issues, - ) - except ImportError: - _fail_and_issue( - "Mem0 plugin not loadable", - "pip install mem0ai", - "Mem0 is set as memory provider but mem0ai is not installed", - issues, - ) - except Exception as _e: - check_warn("Mem0 check failed", str(_e)) - else: - # Generic check for other memory providers (openviking, hindsight, etc.) - try: - from plugins.memory import load_memory_provider - _provider = load_memory_provider(_active_memory_provider) - if _provider and _provider.is_available(): - check_ok(f"{_active_memory_provider} provider active") - elif _provider: - check_warn(f"{_active_memory_provider} configured but not available", "run: hermes memory status") - else: - check_warn(f"{_active_memory_provider} plugin not found", "run: hermes memory setup") - except Exception as _e: - check_warn(f"{_active_memory_provider} check failed", str(_e)) - - try: - from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists - import re as _re - - named_profiles = [p for p in list_profiles() if not p.is_default] - if named_profiles: - _section("Profiles") - check_ok(f"{len(named_profiles)} profile(s) found") - wrapper_dir = _get_wrapper_dir() - for p in named_profiles: - parts = [] - if p.gateway_running: - parts.append("gateway running") - if p.model: - parts.append(p.model[:30]) - if not (p.path / "config.yaml").exists(): - parts.append("⚠ missing config") - if not (p.path / ".env").exists(): - parts.append("no .env") - wrapper = wrapper_dir / p.name - if not wrapper.exists(): - parts.append("no alias") - status = ", ".join(parts) if parts else "configured" - check_ok(f" {p.name}: {status}") - - # Check for orphan wrappers - if wrapper_dir.is_dir(): - for wrapper in wrapper_dir.iterdir(): - if not wrapper.is_file(): - continue - try: - content = wrapper.read_text() - if "hermes -p" in content: - _m = _re.search(r"hermes -p (\S+)", content) - if _m and not profile_exists(_m.group(1)): - check_warn(f"Orphan alias: {wrapper.name} → profile '{_m.group(1)}' no longer exists") - except Exception: - pass - except ImportError: - pass - except Exception: - pass - - print() - remaining_issues = issues + manual_issues - if should_fix and fixed_count > 0: - print(color("─" * 60, Colors.GREEN)) - print(color(f" Fixed {fixed_count} issue(s).", Colors.GREEN, Colors.BOLD), end="") - if remaining_issues: - print(color(f" {len(remaining_issues)} issue(s) require manual intervention.", Colors.YELLOW, Colors.BOLD)) - else: - print() - print() - if remaining_issues: - for i, issue in enumerate(remaining_issues, 1): - print(f" {i}. {issue}") - print() - elif remaining_issues: - print(color("─" * 60, Colors.YELLOW)) - print(color(f" Found {len(remaining_issues)} issue(s) to address:", Colors.YELLOW, Colors.BOLD)) - print() - for i, issue in enumerate(remaining_issues, 1): - print(f" {i}. {issue}") - print() - if not should_fix: - print(color(" Tip: run 'hermes doctor --fix' to auto-fix what's possible.", Colors.DIM)) - else: - print(color("─" * 60, Colors.GREEN)) - print(color(" All checks passed! 🎉", Colors.GREEN, Colors.BOLD)) - - print() diff --git a/hermes_cli/doctor/__init__.py b/hermes_cli/doctor/__init__.py new file mode 100644 index 0000000000..9e454f2dcc --- /dev/null +++ b/hermes_cli/doctor/__init__.py @@ -0,0 +1,165 @@ +"""hermes_cli.doctor — diagnostic checks for Hermes Agent setup. + +This module is the public face of the doctor package. It exposes the same +names the old flat doctor.py did so all existing imports and monkeypatches +in tests keep working without change: + + from hermes_cli.doctor import run_doctor, HERMES_HOME, PROJECT_ROOT, _DHH + from hermes_cli.doctor import _has_provider_env_config, _PROVIDER_ENV_HINTS + from hermes_cli.doctor import _apply_doctor_tool_availability_overrides + from hermes_cli.doctor import _honcho_is_configured_for_doctor + from hermes_cli.doctor import _doctor_tool_availability_detail + from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider + from hermes_cli.doctor import _build_apikey_providers_list + from hermes_cli.doctor import shutil # tests patch shutil.which via doctor_mod.shutil + import hermes_cli.doctor as doctor_mod # monkeypatching PROJECT_ROOT, HERMES_HOME etc. + +Internal layout +--------------- +hermes_cli/doctor/ + __init__.py ← you are here + _output.py ← ANSI rendering helpers (no external deps) + _registry.py ← register() decorator, DiagnosticReport, run_checks() + checks/ + __init__.py ← imports all check modules (registers side-effects) + _helpers.py ← shared utils (safe_which, is_termux, …) + python_env.py + security.py + dep_mgmt.py + config_files.py + xai_retirement.py + auth_providers.py + directory_structure.py + gateway_service.py + command_install.py + external_tools.py + api_connectivity.py + tool_availability.py + skills_hub.py + memory_provider.py + profiles.py +""" + +from __future__ import annotations + +import os +import shutil # noqa: F401 — tests monkeypatch hermes_cli.doctor.shutil +import sys +from pathlib import Path + +# ── Module-level globals (monkeypatched in tests) ───────────────────────── +# These are the same names the flat doctor.py exposed. + +from hermes_cli.config import get_hermes_home, get_env_path +from hermes_constants import display_hermes_home, get_hermes_source_root + +PROJECT_ROOT = get_hermes_source_root() +HERMES_HOME = get_hermes_home() +_DHH = display_hermes_home() + +# ── Lazy env bootstrap — same as old doctor.py top-level code ───────────── +_env_path = get_env_path() +try: + from hermes_cli.env_loader import load_hermes_dotenv + load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env") +except Exception: + pass + +# ── Backward-compat re-exports from sub-modules ─────────────────────────── +# Import these after the globals so sub-modules can `from hermes_cli.doctor import HERMES_HOME` + +from hermes_cli.doctor.checks.config_files import ( # noqa: F401 + _PROVIDER_ENV_HINTS, + _has_provider_env_config, +) +from hermes_cli.doctor.checks.tool_availability import ( # noqa: F401 + _apply_doctor_tool_availability_overrides, + _honcho_is_configured_for_doctor, + _doctor_tool_availability_detail, +) +from hermes_cli.doctor.checks.api_connectivity import ( # noqa: F401 + _has_healthy_oauth_fallback as _has_healthy_oauth_fallback_for_apikey_provider, + _build_apikey_providers_list, + _APIKEY_PROVIDERS_CACHE, +) + +# Termux helpers (some tests import directly) +from hermes_cli.doctor.checks._helpers import ( # noqa: F401 + is_termux as _is_termux, + python_install_cmd as _python_install_cmd, + system_package_install_cmd as _system_package_install_cmd, +) + +# ── Platform check ──────────────────────────────────────────────────────── + +def _check_unsupported_platform() -> None: + if sys.platform == "darwin" and os.uname().machine == "x86_64": + from hermes_cli.doctor._output import color, _Ansi + print(color( + "⚠️ WARNING: macOS x86_64 (Intel) is explicitly unsupported.\n" + "We no longer accept PRs or provide fixes for this platform.\n" + "Consider migrating to a supported platform (macOS arm64 / Apple Silicon).", + _Ansi.YELLOW, + )) + print() + + +# ── run_doctor entry point ──────────────────────────────────────────────── + +def run_doctor(args) -> None: + """Run all registered diagnostic checks. + + Called by ``hermes doctor`` (via ``hermes_cli/main.py``) and also + directly by tests. ``args`` is an ``argparse.Namespace`` with at least: + args.fix — bool, whether to attempt auto-fixes + args.ack — str | None, advisory ID to acknowledge + """ + should_fix = getattr(args, "fix", False) + ack_target = getattr(args, "ack", None) + + os.environ.setdefault("HERMES_INTERACTIVE", "1") + + # Fast path: `hermes doctor --ack ` + if ack_target: + _handle_ack(ack_target) + return + + # Trigger all @register() decorators by importing the checks package + import hermes_cli.doctor.checks # noqa: F401 + + from hermes_cli.doctor._output import print_banner, color, _Ansi + from hermes_cli.doctor._registry import DiagnosticReport, run_checks + + print_banner() + _check_unsupported_platform() + + report = DiagnosticReport(should_fix=should_fix) + run_checks(report) + report.print_summary() + + +def _handle_ack(ack_target: str) -> None: + from hermes_cli.doctor._output import color, _Ansi + from hermes_cli.security_advisories import ADVISORIES, ack_advisory + + valid_ids = {a.id for a in ADVISORIES} + if ack_target not in valid_ids: + print(color( + f"Unknown advisory ID: {ack_target!r}. Known IDs: " + f"{', '.join(sorted(valid_ids)) or '(none)'}", + _Ansi.RED, + )) + sys.exit(2) + if ack_advisory(ack_target): + print(color( + f" ✓ Acknowledged advisory {ack_target}. " + f"It will no longer trigger startup banners.", + _Ansi.GREEN, + )) + else: + print(color( + f" ✗ Failed to persist ack for {ack_target}. " + f"Check ~/.hermes/config.yaml is writable.", + _Ansi.RED, + )) + sys.exit(1) diff --git a/hermes_cli/doctor/_output.py b/hermes_cli/doctor/_output.py new file mode 100644 index 0000000000..ba3ec9d1a0 --- /dev/null +++ b/hermes_cli/doctor/_output.py @@ -0,0 +1,94 @@ +"""Terminal output rendering for doctor diagnostics. + +Pure stdlib — uses inline ANSI codes instead of importing hermes_cli.colors. +""" + +from __future__ import annotations + +import os +import sys + + +def _should_use_color() -> bool: + """Return True when colored output is appropriate. + + Respects NO_COLOR (https://no-color.org/) and TERM=dumb. + """ + if os.environ.get("NO_COLOR") is not None: + return False + if os.environ.get("TERM") == "dumb": + return False + try: + if not sys.stdout.isatty(): + return False + except (AttributeError, ValueError): + return False + return True + + +class _Ansi: + """ANSI escape code constants.""" + RESET = "\033[0m" + BOLD = "\033[1m" + DIM = "\033[2m" + RED = "\033[31m" + GREEN = "\033[32m" + YELLOW = "\033[33m" + BLUE = "\033[34m" + MAGENTA = "\033[35m" + CYAN = "\033[36m" + + +def color(text: str, *codes: str) -> str: + """Apply ANSI color codes to text (only when color output is appropriate).""" + if not _should_use_color(): + return text + return "".join(codes) + text + _Ansi.RESET + + +def check_ok(text: str, detail: str = "") -> None: + """Print an OK check line.""" + glyph = color("✓", _Ansi.GREEN) + line = f" {glyph} {text}" + if detail: + line += f" {color(detail, _Ansi.DIM)}" + print(line) + + +def check_warn(text: str, detail: str = "") -> None: + """Print a warning check line.""" + glyph = color("⚠", _Ansi.YELLOW) + line = f" {glyph} {text}" + if detail: + line += f" {color(detail, _Ansi.DIM)}" + print(line) + + +def check_fail(text: str, detail: str = "") -> None: + """Print a failure check line.""" + glyph = color("✗", _Ansi.RED) + line = f" {glyph} {text}" + if detail: + line += f" {color(detail, _Ansi.DIM)}" + print(line) + + +def check_info(text: str) -> None: + """Print an informational check line.""" + glyph = color("→", _Ansi.CYAN) + print(f" {glyph} {text}") + + +def section(title: str) -> None: + """Print a section banner: blank line + bold cyan ◆ title.""" + print() + print(color(f"◆ {title}", _Ansi.CYAN, _Ansi.BOLD)) + + +def print_banner() -> None: + """Print the doctor header banner.""" + print() + print(color("┌─────────────────────────────────────────────────────────┐", _Ansi.CYAN)) + print(color("│ 🩺 Hermes Doctor │", _Ansi.CYAN)) + print(color("└─────────────────────────────────────────────────────────┘", _Ansi.CYAN)) + print() diff --git a/hermes_cli/doctor/_registry.py b/hermes_cli/doctor/_registry.py new file mode 100644 index 0000000000..66ba396a60 --- /dev/null +++ b/hermes_cli/doctor/_registry.py @@ -0,0 +1,356 @@ +"""Check registration and diagnostic reporting framework. + +Pure stdlib — no external dependencies. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass, field +from typing import Callable + +from hermes_cli.doctor._output import ( + check_ok, + check_warn, + check_fail, + check_info, + section as _print_section, + color, + _Ansi, +) + + +# ── Registry ────────────────────────────────────────────────────────────── + +@dataclass +class RegisteredCheck: + section: str + name: str + fn: Callable + priority: int = 0 + + +_CHECKS: list[RegisteredCheck] = [] + + +def register(section: str, name: str = "", priority: int = 0) -> Callable: + """Decorator to register a diagnostic check. + + Args: + section: Section heading this check appears under. + name: Short identifier for this specific sub-check. + Used in the auto-caught exception message. + Defaults to the function name. + priority: Run order within the section — lower runs first (default 0). + + The decorated function receives a single ``DiagnosticReport`` argument. + Any uncaught exception is caught by the runner, which emits a ⚠ warning + and continues — checks don't need defensive blanket try/except. + + Example:: + + @register("Python Environment", "python-version") + def check_python_version(report): + py = sys.version_info + if py >= (3, 11): + report.ok(f"Python {py.major}.{py.minor}.{py.micro}") + else: + report.fail( + "Python too old", + detail="(3.11+ required)", + fix="Upgrade Python to 3.10+", + ) + + For auto-fixable issues pass a ``fix_fn`` callable to ``report.fail`` or + ``report.add_issue``:: + + @register("Config Files", "env-file") + def check_env_file(report): + if not env_path.exists(): + def _fix(report): + env_path.touch() + report.ok("Created empty .env") + report.fail(".env missing", fix="run hermes setup", fix_fn=_fix) + """ + def decorator(fn: Callable) -> Callable: + _CHECKS.append(RegisteredCheck( + section=section, + name=name or fn.__name__, + fn=fn, + priority=priority, + )) + return fn + return decorator + + +def get_registered_checks() -> list[RegisteredCheck]: + """Return all registered checks in run order.""" + seen: dict[str, int] = {} + order = 0 + for c in _CHECKS: + if c.section not in seen: + seen[c.section] = order + order += 1 + return sorted(_CHECKS, key=lambda c: (seen[c.section], c.priority, c.name)) + + +# ── Issue record ────────────────────────────────────────────────────────── + +@dataclass +class _Issue: + """An issue collected during a check run.""" + text: str + fix_fn: Callable | None = None # None → manual-only + section: str = "" + check: str = "" + + +@dataclass +class _Warning: + """A warning collected during a check run.""" + text: str + section: str = "" + check: str = "" + + +# ── Diagnostic Report ──────────────────────────────────────────────────── + +class DiagnosticReport: + """Passed to every check function. + + Section headers are deferred: a header only prints when the first + finding (ok/warn/fail/info) is emitted under it, so checks that + return early without output never print stray banners. + + Fix model + --------- + Every ``fail()`` and ``add_issue()`` call accepts an optional + ``fix_fn`` and an optional human-readable ``fix`` string. + + * ``fix_fn`` — zero-arg callable executed when ``--fix`` is active. + It receives the report so it can emit ok/warn/fail/info + lines describing what it did. + * ``fix`` — short instruction shown in the issue summary when + ``--fix`` is NOT active or when no ``fix_fn`` was given. + + In ``--fix`` mode: + - issues *with* a ``fix_fn`` are executed immediately; the issue + is removed from the summary if the fn succeeds. + - issues *without* a ``fix_fn`` remain in the summary as manual. + + In normal mode: + - issues *with* a ``fix_fn`` are shown as "✦ fixable — re-run with --fix" + - issues *without* a ``fix_fn`` are shown as "✗ manual" + """ + + def __init__(self, should_fix: bool = False) -> None: + self._should_fix = should_fix + self._issues: list[_Issue] = [] + self._fixed_issues: list[_Issue] = [] + self._warnings: list[_Warning] = [] + self._fixed: int = 0 + self._pending_section: str | None = None + self._printed_section: str | None = None + # Set by the runner before each check so findings know their origin + self._current_section: str = "" + self._current_check: str = "" + + # ── Section management ──────────────────────────────────────────────── + + def section(self, title: str) -> None: + """Declare the current section (header is deferred until first output).""" + self._pending_section = title + self._current_section = title + + def _flush_section(self) -> None: + if self._pending_section and self._pending_section != self._printed_section: + _print_section(self._pending_section) + self._printed_section = self._pending_section + + # ── Finding emitters ───────────────────────────────────────────────── + + def ok(self, text: str, detail: str = "") -> None: + self._flush_section() + check_ok(text, detail) + + def warn(self, text: str, detail: str = "") -> None: + self._flush_section() + check_warn(text, detail) + label = text + (f" {detail}" if detail else "") + self._warnings.append(_Warning(label, self._current_section, self._current_check)) + + def fail( + self, + text: str, + detail: str = "", + *, + fix: str = "", + fix_fn: Callable | None = None, + ) -> None: + """Emit a ✗ failure line and record the issue. + + Args: + text: Primary description of the problem. + detail: Optional dim detail suffix on the same line. + fix: Short human-readable instruction shown in the summary. + fix_fn: Zero-arg callable that auto-fixes the problem when + called. It receives this report for output. If + ``--fix`` is active it is called immediately; + otherwise the issue is annotated as auto-fixable. + """ + self._flush_section() + check_fail(text, detail) + self._record_issue(fix or text, fix_fn) + + def info(self, text: str) -> None: + self._flush_section() + check_info(text) + + def add_issue( + self, + text: str, + *, + fix_fn: Callable | None = None, + ) -> None: + """Add an issue to the summary without printing a fail line. + + Use when a check wants to register a problem that was already + surfaced via warn() but still deserves a summary entry. + + Args: + text: Issue description for the summary. + fix_fn: Optional auto-fix callable (same semantics as fail). + """ + self._record_issue(text, fix_fn) + + # ── Internal ───────────────────────────────────────────────────────── + + def _record_issue(self, text: str, fix_fn: Callable | None) -> None: + issue = _Issue(text, fix_fn, self._current_section, self._current_check) + if self._should_fix and fix_fn is not None: + try: + fix_fn(self) + self._fixed += 1 + self._fixed_issues.append(issue) + return + except Exception as exc: + check_warn(f"Auto-fix failed", f"({type(exc).__name__}: {exc})") + # Fall through and add to remaining issues + self._issues.append(issue) + + # ── Colour helpers ──────────────────────────────────────────────────── + + def color(self, text: str, *codes: str) -> str: + return color(text, *codes) + + GREEN = _Ansi.GREEN + YELLOW = _Ansi.YELLOW + RED = _Ansi.RED + CYAN = _Ansi.CYAN + DIM = _Ansi.DIM + BOLD = _Ansi.BOLD + + def raw_print(self, text: str = "") -> None: + self._flush_section() + print(text) + + # ── Summary ─────────────────────────────────────────────────────────── + + def print_summary(self) -> None: + fixable = [i for i in self._issues if i.fix_fn is not None] + manual = [i for i in self._issues if i.fix_fn is None] + + print() + + # ── --fix mode: report what was done, then leftover sections ───── + if self._should_fix and self._fixed > 0: + if not self._issues: + print(color("─" * 60, _Ansi.GREEN)) + print(color( + f" ✓ Fixed {self._fixed} issue(s). All checks passed! 🎉", + _Ansi.GREEN, _Ansi.BOLD, + )) + print() + _render_grouped(self._fixed_issues, bullet="✓") + print() + return + print(color("─" * 60, _Ansi.YELLOW)) + print( + color(f" ✓ Fixed {self._fixed} issue(s).", _Ansi.GREEN, _Ansi.BOLD) + + color(f" {len(self._issues)} still require attention.", _Ansi.YELLOW) + ) + print() + _render_grouped(self._fixed_issues, bullet="✓") + print() + + # ── All-clear ──────────────────────────────────────────────────── + elif not self._issues: + print(color("─" * 60, _Ansi.GREEN)) + print(color(" All checks passed! 🎉", _Ansi.GREEN, _Ansi.BOLD)) + print() + return + + # ── Header (no-fix mode) ───────────────────────────────────────── + else: + print(color("─" * 60, _Ansi.YELLOW)) + + # ── Auto-fixable issues ────────────────────────────────────────── + if fixable: + print(color( + f" ✦ {len(fixable)} auto-fixable issue{"" if len(fixable) == 1 else "s"}" + + (" — run `hermes doctor --fix` to resolve:" if not self._should_fix else ":"), + _Ansi.CYAN, _Ansi.BOLD, + )) + _render_grouped(fixable) + print() + + # ── Manual issues ──────────────────────────────────────────────── + if manual: + print(color(f" ✗ {len(manual)} issue{"" if len(manual) == 1 else "s"} require manual attention:", _Ansi.RED, _Ansi.BOLD)) + _render_grouped(manual) + print() + + # ── Warnings (informational, not blocking) ─────────────────────── + if self._warnings: + print(color(f" ⚠ {len(self._warnings)} warning{"" if len(self._warnings) == 1 else "s"}:", _Ansi.YELLOW, _Ansi.BOLD)) + _render_grouped(self._warnings) + print() + + + +def _fmt_label(section: str, check: str) -> str: + """Unused — kept for any external callers; grouping is now done in print_summary.""" + return "" + + +def _render_grouped(items: list, bullet: str = "•") -> None: + """Render a list of _Issue or _Warning grouped by section, with sub-headers.""" + # Preserve insertion order of sections + seen: dict[str, list] = {} + for item in items: + key = item.section or "" + seen.setdefault(key, []).append(item) + + for section_name, group in seen.items(): + if section_name: + print(f" {color(section_name, _Ansi.DIM)}") + for item in group: + text = item.text if hasattr(item, "text") else item + print(f" {bullet} {text}") + + +# ── Runner ──────────────────────────────────────────────────────────────── + +def run_checks(report: DiagnosticReport) -> None: + """Run all registered checks, catching any unexpected exceptions.""" + for check in get_registered_checks(): + report.section(check.section) + report._current_section = check.section + report._current_check = check.name + try: + check.fn(report) + except Exception as exc: + report.warn( + f"Check '{check.name}' failed unexpectedly", + f"({type(exc).__name__}: {exc})", + ) diff --git a/hermes_cli/doctor/_types.py b/hermes_cli/doctor/_types.py new file mode 100644 index 0000000000..807ad59fc9 --- /dev/null +++ b/hermes_cli/doctor/_types.py @@ -0,0 +1,65 @@ +"""Core types for the doctor diagnostic framework. + +Pure stdlib — no external dependencies. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Callable + + +class Severity(Enum): + """Diagnostic severity levels.""" + OK = "ok" + INFO = "info" + WARN = "warn" + FAIL = "fail" + + +@dataclass +class Finding: + """A single diagnostic finding from a check.""" + severity: Severity + text: str + detail: str = "" + fix: str = "" # Fix instruction for the summary + auto_fixable: bool = False + + +# Convenience constructors +def ok(text: str, detail: str = "") -> Finding: + """Create an OK finding.""" + return Finding(Severity.OK, text, detail) + + +def info(text: str) -> Finding: + """Create an informational finding.""" + return Finding(Severity.INFO, text) + + +def warn(text: str, detail: str = "") -> Finding: + """Create a warning finding.""" + return Finding(Severity.WARN, text, detail) + + +def fail(text: str, detail: str = "", fix: str = "") -> Finding: + """Create a failure finding with optional fix instruction.""" + return Finding(Severity.FAIL, text, detail, fix) + + +@dataclass +class CheckResult: + """Result from running a single check function.""" + section: str + findings: list[Finding] = field(default_factory=list) + + +@dataclass +class Check: + """A registered diagnostic check.""" + name: str + section: str + fn: Callable # (ctx: DoctorContext, report: DiagnosticReport) -> None + priority: int = 0 # Ordering within section (lower = earlier) diff --git a/hermes_cli/doctor/checks/__init__.py b/hermes_cli/doctor/checks/__init__.py new file mode 100644 index 0000000000..98aaad4d9b --- /dev/null +++ b/hermes_cli/doctor/checks/__init__.py @@ -0,0 +1,19 @@ +"""Doctor checks package — importing this registers all checks.""" + +from hermes_cli.doctor.checks import ( # noqa: F401 — side-effect imports + python_env, + security, + dep_mgmt, + config_files, + xai_retirement, + auth_providers, + directory_structure, + gateway_service, + command_install, + external_tools, + api_connectivity, + tool_availability, + skills_hub, + memory_provider, + profiles, +) diff --git a/hermes_cli/doctor/checks/_helpers.py b/hermes_cli/doctor/checks/_helpers.py new file mode 100644 index 0000000000..e1a0757105 --- /dev/null +++ b/hermes_cli/doctor/checks/_helpers.py @@ -0,0 +1,123 @@ +"""Shared utility functions for doctor checks. + +Pure stdlib — no external dependencies. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + + +def _safe_which(cmd: str) -> str | None: + """shutil.which wrapper resilient to platform monkeypatching in tests.""" + try: + return shutil.which(cmd) + except Exception: + return None + + +def is_termux() -> bool: + """Return True when running inside Termux on Android.""" + return bool( + os.environ.get("TERMUX_VERSION") + or "com.termux/files" in os.environ.get("PREFIX", "") + ) + + +# Re-export as the private name tests expect +_is_termux = is_termux + + +def python_install_cmd() -> str: + """Return the pip install command appropriate for the platform.""" + return "python -m pip install" if is_termux() else "uv pip install" + + +def system_package_install_cmd(pkg: str) -> str: + """Return the package manager install command for the given package.""" + if is_termux(): + return f"pkg install {pkg}" + if sys.platform == "darwin": + return f"brew install {pkg}" + return f"sudo apt install {pkg}" + + +def termux_browser_setup_steps(node_installed: bool) -> list[str]: + """Return ordered setup steps for browser tools on Termux.""" + steps: list[str] = [] + step = 1 + if not node_installed: + steps.append(f"{step}) pkg install nodejs") + step += 1 + steps.append(f"{step}) npm install -g agent-browser") + steps.append(f"{step + 1}) agent-browser install") + return steps + + +def termux_install_all_fallback_notes() -> list[str]: + """Return informational notes for Termux compatibility.""" + return [ + "Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).", + "Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).", + "Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).", + "STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).", + ] + + +def resolve_project_root() -> Path: + """Resolve the hermes-agent project root directory. + + Lightweight stdlib-only resolution: walks up from this file to find + pyproject.toml, which marks the project root. + """ + # This file is at hermes_cli/doctor/checks/_helpers.py + # Project root is 4 levels up + here = Path(__file__).resolve() + candidate = here.parent.parent.parent.parent # hermes_cli/doctor/checks/_helpers.py -> hermes_cli -> project root + # Verify by checking pyproject.toml or setup.py exists + if (candidate / "pyproject.toml").exists() or (candidate / "setup.py").exists(): + return candidate + # Fallback: try parent.parent.parent (if the file moved) + for parent in here.parents: + if (parent / "pyproject.toml").exists(): + return parent + # Last resort + return candidate + + +def resolve_hermes_home() -> Path: + """Resolve the Hermes home directory. + + Reads HERMES_HOME env var, falls back to platform-native default. + Lightweight stdlib-only implementation. + """ + val = os.environ.get("HERMES_HOME", "").strip() + if val: + return Path(val) + if sys.platform == "win32": + local_appdata = os.environ.get("LOCALAPPDATA", "").strip() + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + return base / "hermes" + # Check for active profile + default_home = Path.home() / ".hermes" + try: + active_path = default_home / "active_profile" + active = active_path.read_text().strip() if active_path.exists() else "" + except (OSError, UnicodeDecodeError): + active = "" + if active and active != "default": + return default_home / "profiles" / active + return default_home + + +def resolve_display_hermes_home() -> str: + """Return a user-friendly display path for HERMES_HOME.""" + home = resolve_hermes_home() + try: + rel = home.relative_to(Path.home()) + return f"~/{rel}" + except ValueError: + return str(home) diff --git a/hermes_cli/doctor/checks/api_connectivity.py b/hermes_cli/doctor/checks/api_connectivity.py new file mode 100644 index 0000000000..71c165de7b --- /dev/null +++ b/hermes_cli/doctor/checks/api_connectivity.py @@ -0,0 +1,304 @@ +"""API connectivity checks — run in parallel.""" + +from __future__ import annotations + +import concurrent.futures +import os + +from hermes_cli.doctor._registry import register +from hermes_cli.doctor._output import color, _Ansi + + +# ── Individual probes ───────────────────────────────────────────────────── +# Each returns (label, lines, issues) where lines = list of (glyph, label, detail). + +def _probe_openrouter(): + from hermes_constants import OPENROUTER_MODELS_URL + from hermes_cli.models import _HERMES_USER_AGENT + + key = os.getenv("OPENROUTER_API_KEY") + if not key: + return ("OpenRouter API", [(color("⚠", _Ansi.YELLOW), "OpenRouter API", color("(not configured)", _Ansi.DIM))], []) + + import httpx + r = httpx.get(OPENROUTER_MODELS_URL, headers={"Authorization": f"Bearer {key}"}, timeout=10) + if r.status_code == 200: + return ("OpenRouter API", [(color("✓", _Ansi.GREEN), "OpenRouter API", "")], []) + if r.status_code == 401: + return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(invalid API key)", _Ansi.DIM))], ["Check OPENROUTER_API_KEY in .env"]) + if r.status_code == 402: + return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(out of credits — payment required)", _Ansi.DIM))], + ["OpenRouter account has insufficient credits. Fix: run 'hermes config set model.provider ' to switch providers, or fund your OpenRouter account at https://openrouter.ai/settings/credits"]) + if r.status_code == 429: + return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color("(rate limited)", _Ansi.DIM))], + ["OpenRouter rate limit hit — consider switching to a different provider or waiting"]) + return ("OpenRouter API", [(color("✗", _Ansi.RED), "OpenRouter API", color(f"(HTTP {r.status_code})", _Ansi.DIM))], []) + + +def _probe_anthropic(): + from hermes_cli.auth import get_anthropic_key + key = get_anthropic_key() + if not key: + return ("Anthropic API", [], []) + + import httpx + from agent.anthropic_adapter import _is_oauth_token, _COMMON_BETAS, _OAUTH_ONLY_BETAS, _CONTEXT_1M_BETA + headers = {"anthropic-version": "2023-06-01"} + is_oauth = _is_oauth_token(key) + if is_oauth: + headers["Authorization"] = f"Bearer {key}" + headers["anthropic-beta"] = ",".join(_COMMON_BETAS + _OAUTH_ONLY_BETAS) + else: + headers["x-api-key"] = key + r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10) + if is_oauth and r.status_code == 400 and "long context beta" in r.text.lower() and "not yet available" in r.text.lower(): + headers["anthropic-beta"] = ",".join( + [b for b in _COMMON_BETAS if b != _CONTEXT_1M_BETA] + list(_OAUTH_ONLY_BETAS) + ) + r = httpx.get("https://api.anthropic.com/v1/models", headers=headers, timeout=10) + if r.status_code == 200: + return ("Anthropic API", [(color("✓", _Ansi.GREEN), "Anthropic API", "")], []) + if r.status_code == 401: + return ("Anthropic API", [(color("✗", _Ansi.RED), "Anthropic API", color("(invalid API key)", _Ansi.DIM))], []) + return ("Anthropic API", [(color("⚠", _Ansi.YELLOW), "Anthropic API", color("(couldn't verify)", _Ansi.DIM))], []) + + +def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_hc): + key = "" + for ev in env_vars: + key = os.getenv(ev, "") + if key: + break + if not key: + return (pname, [], []) + + label = pname.ljust(20) + if not supports_hc: + return (pname, [(color("✓", _Ansi.GREEN), label, color("(key configured)", _Ansi.DIM))], []) + + import httpx + from hermes_cli.models import _HERMES_USER_AGENT + from utils import base_url_host_matches + + base = os.getenv(base_env, "") if base_env else "" + if not base and key.startswith("sk-kimi-"): + base = "https://api.kimi.com/coding/v1" + if base and base.rstrip("/").endswith("/anthropic"): + from agent.auxiliary_client import _to_openai_base_url + base = _to_openai_base_url(base) + if base_url_host_matches(base, "api.kimi.com") and base.rstrip("/").endswith("/coding"): + base = base.rstrip("/") + "/v1" + url = (base.rstrip("/") + "/models") if base else default_url + headers = {"Authorization": f"Bearer {key}", "User-Agent": _HERMES_USER_AGENT} + if base_url_host_matches(base, "api.kimi.com"): + headers["User-Agent"] = "claude-code/0.1.0" + if url and base_url_host_matches(url, "generativelanguage.googleapis.com"): + headers.pop("Authorization", None) + headers["x-goog-api-key"] = key + r = httpx.get(url, headers=headers, timeout=10) + if pname == "Alibaba/DashScope" and not base and r.status_code == 401: + r = httpx.get("https://dashscope.aliyuncs.com/compatible-mode/v1/models", headers=headers, timeout=10) + if r.status_code == 200: + return (pname, [(color("✓", _Ansi.GREEN), label, "")], []) + if r.status_code == 401: + return (pname, [(color("✗", _Ansi.RED), label, color("(invalid API key)", _Ansi.DIM))], [f"Check {env_vars[0]} in .env"]) + return (pname, [(color("⚠", _Ansi.YELLOW), label, color(f"(HTTP {r.status_code})", _Ansi.DIM))], []) + + +def _probe_bedrock(): + from agent.bedrock_adapter import has_aws_credentials, resolve_aws_auth_env_var, resolve_bedrock_region + if not has_aws_credentials(): + return ("AWS Bedrock", [], []) + + import boto3 + from botocore.config import Config as _BotoConfig + + auth_var = resolve_aws_auth_env_var() + region = resolve_bedrock_region() + label = "AWS Bedrock".ljust(20) + cfg = _BotoConfig(connect_timeout=5, read_timeout=10, retries={"max_attempts": 1}) + client = boto3.client("bedrock", region_name=region, config=cfg) + resp = client.list_foundation_models() + n = len(resp.get("modelSummaries", [])) + return ("AWS Bedrock", [(color("✓", _Ansi.GREEN), label, color(f"({auth_var}, {region}, {n} models)", _Ansi.DIM))], []) + + +def _probe_azure_entra(): + from hermes_cli.config import load_config + cfg = load_config() + model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} + if not isinstance(model_cfg, dict): + return ("Azure Foundry (Entra ID)", [], []) + if str(model_cfg.get("provider") or "").strip().lower() != "azure-foundry": + return ("Azure Foundry (Entra ID)", [], []) + if str(model_cfg.get("auth_mode") or "").strip().lower() != "entra_id": + return ("Azure Foundry (Entra ID)", [], []) + + label = "Azure Foundry (Entra ID)".ljust(28) + from agent.azure_identity_adapter import ( + EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, + describe_active_credential, has_azure_identity_installed, + ) + if not has_azure_identity_installed(): + return ("Azure Foundry (Entra ID)", + [(color("⚠", _Ansi.YELLOW), label, color("(azure-identity not installed)", _Ansi.DIM))], + ["Install azure-identity: uv pip install azure-identity"]) + + entra_cfg = model_cfg.get("entra") or {} + if not isinstance(entra_cfg, dict): + entra_cfg = {} + scope = str(entra_cfg.get("scope") or "").strip() or SCOPE_AI_AZURE_DEFAULT + info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0) + if info.get("ok"): + env_sources = info.get("env_sources") or [] + tag = ", ".join(env_sources) if env_sources else "default credential chain" + return ("Azure Foundry (Entra ID)", + [(color("✓", _Ansi.GREEN), label, color(f"({tag}, scope={scope})", _Ansi.DIM))], []) + err = info.get("error") or "credential chain exhausted" + hint = info.get("hint") or ( + "Run `az login`, set AZURE_TENANT_ID/AZURE_CLIENT_ID/AZURE_CLIENT_SECRET, " + "or attach a managed identity to this VM." + ) + return ("Azure Foundry (Entra ID)", + [(color("⚠", _Ansi.YELLOW), label, color(f"({err})", _Ansi.DIM))], + [f"Azure Foundry Entra: {err}. {hint}"]) + + +def _has_healthy_oauth_fallback(provider_label: str) -> bool: + normalized = (provider_label or "").strip().lower() + if normalized in {"google / gemini", "gemini"}: + try: + from hermes_cli.auth import get_gemini_oauth_auth_status + return bool((get_gemini_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False + if normalized == "minimax": + try: + from hermes_cli.auth import get_minimax_oauth_auth_status + return bool((get_minimax_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False + if normalized == "xai": + try: + from hermes_cli.auth import get_xai_oauth_auth_status + return bool((get_xai_oauth_auth_status() or {}).get("logged_in")) + except Exception: + return False + return False + + +# Cache for the expensive provider list build +_APIKEY_PROVIDERS_CACHE: list | None = None + + +def _build_apikey_providers_list() -> list: + """Build the API-key provider health-check list and cache it.""" + _static = [ + ("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), + ("Kimi / Moonshot", ("KIMI_API_KEY",), "https://api.moonshot.ai/v1/models", "KIMI_BASE_URL", True), + ("StepFun Step Plan", ("STEPFUN_API_KEY",), "https://api.stepfun.ai/step_plan/v1/models", "STEPFUN_BASE_URL", True), + ("Kimi / Moonshot (China)", ("KIMI_CN_API_KEY",), "https://api.moonshot.cn/v1/models", None, True), + ("Arcee AI", ("ARCEEAI_API_KEY",), "https://api.arcee.ai/api/v1/models", "ARCEE_BASE_URL", True), + ("GMI Cloud", ("GMI_API_KEY",), "https://api.gmi-serving.com/v1/models", "GMI_BASE_URL", True), + ("DeepSeek", ("DEEPSEEK_API_KEY",), "https://api.deepseek.com/v1/models", "DEEPSEEK_BASE_URL", True), + ("Hugging Face", ("HF_TOKEN",), "https://router.huggingface.co/v1/models", "HF_BASE_URL", True), + ("NVIDIA NIM", ("NVIDIA_API_KEY",), "https://integrate.api.nvidia.com/v1/models", "NVIDIA_BASE_URL", True), + ("Alibaba/DashScope", ("DASHSCOPE_API_KEY",), "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models", "DASHSCOPE_BASE_URL", True), + ("MiniMax", ("MINIMAX_API_KEY",), "https://api.minimax.io/v1/models", "MINIMAX_BASE_URL", True), + ("MiniMax (China)", ("MINIMAX_CN_API_KEY",), "https://api.minimaxi.com/v1/models", "MINIMAX_CN_BASE_URL", False), + ("Kilo Code", ("KILOCODE_API_KEY",), "https://api.kilo.ai/api/gateway/models", "KILOCODE_BASE_URL", True), + ("OpenCode Zen", ("OPENCODE_ZEN_API_KEY",), "https://opencode.ai/zen/v1/models", "OPENCODE_ZEN_BASE_URL", True), + ("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False), + ] + _known_names = {t[0] for t in _static} + _name_to_canonical = { + "Z.AI / GLM": "zai", "Kimi / Moonshot": "kimi-coding", + "StepFun Step Plan": "stepfun", "Kimi / Moonshot (China)": "kimi-coding-cn", + "Arcee AI": "arcee", "GMI Cloud": "gmi", "DeepSeek": "deepseek", + "Hugging Face": "huggingface", "NVIDIA NIM": "nvidia", + "Alibaba/DashScope": "alibaba", "MiniMax": "minimax", + "MiniMax (China)": "minimax-cn", + "Kilo Code": "kilocode", "OpenCode Zen": "opencode-zen", + "OpenCode Go": "opencode-go", + } + _known_canonical = set(_name_to_canonical.values()) + _dedicated = {"anthropic", "openrouter", "bedrock"} + _known_canonical.update(_dedicated) + + try: + from providers import list_providers + from providers.base import ProviderProfile as _PP + try: + from hermes_cli.providers import normalize_provider as _nrm + except Exception: + def _nrm(n): return (n or "").strip().lower() + + for pp in list_providers(): + if not isinstance(pp, _PP) or pp.auth_type != "api_key" or not pp.env_vars: + continue + label = pp.display_name or pp.name + if label in _known_names or pp.name in _known_canonical: + continue + candidates = {_nrm(pp.name)} | {_nrm(a) for a in (pp.aliases or ())} + if candidates & _dedicated: + continue + key_vars = tuple(v for v in pp.env_vars if not v.endswith(("_BASE_URL", "_URL"))) + base_var = next((v for v in pp.env_vars if v.endswith(("_BASE_URL", "_URL"))), None) + if not key_vars: + continue + models_url = ( + (pp.models_url or (pp.base_url.rstrip("/") + "/models")) if pp.base_url else None + ) + hc = getattr(pp, "supports_health_check", True) + _static.append((label, key_vars, models_url, base_var, hc)) + except Exception: + pass + + return _static + + +@register("API Connectivity", "api-connectivity", priority=10) +def check_api_connectivity(report): + global _APIKEY_PROVIDERS_CACHE + if _APIKEY_PROVIDERS_CACHE is None: + _APIKEY_PROVIDERS_CACHE = _build_apikey_providers_list() + + probes = [("OpenRouter API", _probe_openrouter), ("Anthropic API", _probe_anthropic)] + for pname, env_vars, default_url, base_env, supports in _APIKEY_PROVIDERS_CACHE: + probes.append((pname, lambda p=pname, e=env_vars, u=default_url, b=base_env, s=supports: + _probe_apikey_provider(p, e, u, b, s))) + probes.append(("AWS Bedrock", _probe_bedrock)) + probes.append(("Azure Foundry (Entra ID)", _probe_azure_entra)) + + print( + f" {color(f'Running {len(probes)} connectivity checks in parallel…', _Ansi.DIM)}", + end="", flush=True, + ) + + prev_imds = os.environ.get("AWS_EC2_METADATA_DISABLED") + os.environ["AWS_EC2_METADATA_DISABLED"] = "true" + try: + with concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="doctor-probe") as ex: + futures = [ex.submit(fn) for _, fn in probes] + results = [] + for f in futures: + try: + results.append(f.result()) + except Exception as exc: + results.append((None, [(color("⚠", _Ansi.YELLOW), "probe", color(f"({exc})", _Ansi.DIM))], [])) + finally: + if prev_imds is None: + os.environ.pop("AWS_EC2_METADATA_DISABLED", None) + else: + os.environ["AWS_EC2_METADATA_DISABLED"] = prev_imds + + print("\r" + " " * 70 + "\r", end="") + for label, lines, issues in results: + for glyph, lbl, detail in lines: + if detail: + print(f" {glyph} {lbl} {detail}") + else: + print(f" {glyph} {lbl}") + if issues and not _has_healthy_oauth_fallback(label or ""): + for issue in issues: + report.add_issue(issue) diff --git a/hermes_cli/doctor/checks/auth_providers.py b/hermes_cli/doctor/checks/auth_providers.py new file mode 100644 index 0000000000..3cd556583a --- /dev/null +++ b/hermes_cli/doctor/checks/auth_providers.py @@ -0,0 +1,67 @@ +"""Auth provider checks.""" + +from __future__ import annotations + +from hermes_cli.doctor._registry import register +from hermes_cli.doctor.checks._helpers import _safe_which + + +@register("Auth Providers", "nous-auth", priority=10) +def check_nous_auth(report): + from hermes_cli.auth import get_nous_auth_status + status = get_nous_auth_status() + if status.get("logged_in"): + report.ok("Nous Portal auth", "(logged in)") + else: + report.warn("Nous Portal auth", "(not logged in)") + + +@register("Auth Providers", "codex-auth", priority=20) +def check_codex_auth(report): + from hermes_cli.auth import get_codex_auth_status + status = get_codex_auth_status() + if status.get("logged_in"): + report.ok("OpenAI Codex auth", "(logged in)") + else: + report.warn("OpenAI Codex auth", "(not logged in)") + if status.get("error"): + report.info(status["error"]) + if not _safe_which("codex"): + report.info( + "codex CLI not installed " + "(optional — only required to import tokens from an existing Codex CLI login)" + ) + + +@register("Auth Providers", "gemini-oauth", priority=30) +def check_gemini_oauth(report): + from hermes_cli.auth import get_gemini_oauth_auth_status + status = get_gemini_oauth_auth_status() + if status.get("logged_in"): + pieces = [x for x in [status.get("email"), status.get("project_id") and f"project={status['project_id']}"] if x] + suffix = f" ({', '.join(pieces)})" if pieces else "" + report.ok("Google Gemini OAuth", f"(logged in{suffix})") + else: + report.warn("Google Gemini OAuth", "(not logged in)") + + +@register("Auth Providers", "minimax-oauth", priority=40) +def check_minimax_oauth(report): + from hermes_cli.auth import get_minimax_oauth_auth_status + status = get_minimax_oauth_auth_status() + if status.get("logged_in"): + report.ok("MiniMax OAuth", f"(logged in, region={status.get('region', 'global')})") + else: + report.warn("MiniMax OAuth", "(not logged in)") + + +@register("Auth Providers", "xai-oauth", priority=50) +def check_xai_oauth(report): + from hermes_cli.auth import get_xai_oauth_auth_status + status = get_xai_oauth_auth_status() or {} + if status.get("logged_in"): + report.ok("xAI OAuth", "(logged in)") + else: + report.warn("xAI OAuth", "(not logged in)") + if status.get("error"): + report.info(status["error"]) diff --git a/hermes_cli/doctor/checks/command_install.py b/hermes_cli/doctor/checks/command_install.py new file mode 100644 index 0000000000..2f534a3d34 --- /dev/null +++ b/hermes_cli/doctor/checks/command_install.py @@ -0,0 +1,84 @@ +"""Command installation checks.""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +from hermes_cli.doctor._registry import register + + +@register("Command Installation", "symlink-check", priority=10) +def check_command_installation(report): + if sys.platform == "win32": + return + + from hermes_cli.doctor import PROJECT_ROOT + + venv_bin = None + for venv_name in ("venv", ".venv"): + c = PROJECT_ROOT / venv_name / "bin" / "hermes" + if c.exists(): + venv_bin = c + break + + prefix = os.environ.get("PREFIX", "") + is_termux = bool(os.environ.get("TERMUX_VERSION")) or "com.termux/files/usr" in prefix + if is_termux and prefix: + cmd_dir = Path(prefix) / "bin" + cmd_display = "$PREFIX/bin" + else: + cmd_dir = Path.home() / ".local" / "bin" + cmd_display = "~/.local/bin" + cmd_link = cmd_dir / "hermes" + + if venv_bin is None: + report.warn( + "Venv entry point not found", + "(hermes not in venv/bin/ or .venv/bin/ — reinstall with pip install -e '.[all]')", + ) + report.add_issue( + f"reinstall entry point: cd {PROJECT_ROOT} && source venv/bin/activate && pip install -e '.[all]'" + ) + return + + report.ok(f"Venv entry point exists ({venv_bin.relative_to(PROJECT_ROOT)})") + + if cmd_link.is_symlink(): + target = cmd_link.resolve() + expected = venv_bin.resolve() + if target == expected: + report.ok(f"{cmd_display}/hermes → correct target") + else: + def _fix(r): + cmd_link.unlink() + cmd_link.symlink_to(venv_bin) + r.ok(f"Fixed symlink: {cmd_display}/hermes → {venv_bin}") + + report.warn( + f"{cmd_display}/hermes points to wrong target", + f"(→ {target}, expected → {expected})", + ) + report.add_issue(f"broken symlink at {cmd_display}/hermes", fix_fn=_fix) + elif cmd_link.exists(): + report.ok(f"{cmd_display}/hermes exists (non-symlink)") + else: + def _fix(r): + cmd_dir.mkdir(parents=True, exist_ok=True) + cmd_link.symlink_to(venv_bin) + r.ok(f"Created symlink: {cmd_display}/hermes → {venv_bin}") + path_dirs = os.environ.get("PATH", "").split(os.pathsep) + if str(cmd_dir) not in path_dirs: + r.warn( + f"{cmd_display} is not on your PATH", + '(add it to your shell config: export PATH="$HOME/.local/bin:$PATH")', + ) + r.add_issue(f"add {cmd_display} to your PATH") + + report.fail( + f"{cmd_display}/hermes not found", + "(hermes command may not work outside the venv)", + fix=f"run `hermes doctor --fix` to create symlink", + fix_fn=_fix, + ) diff --git a/hermes_cli/doctor/checks/config_files.py b/hermes_cli/doctor/checks/config_files.py new file mode 100644 index 0000000000..e833f13d51 --- /dev/null +++ b/hermes_cli/doctor/checks/config_files.py @@ -0,0 +1,334 @@ +"""Configuration file checks.""" + +from __future__ import annotations + +import os +import shutil + +from hermes_cli.doctor._registry import register + + +_PROVIDER_ENV_HINTS = ( + "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", + "OPENAI_BASE_URL", "NOUS_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY", + "HF_TOKEN", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY", + "TOKENHUB_API_KEY", +) + + +def _has_provider_env_config(content: str) -> bool: + return any(key in content for key in _PROVIDER_ENV_HINTS) + + +@register("Configuration Files", "env-file", priority=10) +def check_env_file(report): + from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH + + env_path = HERMES_HOME / ".env" + if env_path.exists(): + report.ok(f"{_DHH}/.env file exists") + content = env_path.read_text(encoding="utf-8") + if _has_provider_env_config(content): + report.ok("API key or custom endpoint configured") + else: + report.warn(f"No API key found in {_DHH}/.env") + report.add_issue("run 'hermes setup' to configure API keys") + elif (PROJECT_ROOT / ".env").exists(): + report.ok(".env file exists (in project directory)") + else: + def _fix(r): + env_path.parent.mkdir(parents=True, exist_ok=True) + env_path.touch() + try: + os.chmod(str(env_path), 0o600) + except OSError: + pass + r.ok(f"Created empty {_DHH}/.env") + r.info("run 'hermes setup' to configure API keys") + + report.fail( + f"{_DHH}/.env file missing", + fix="run 'hermes setup' to create one", + fix_fn=_fix, + ) + + +@register("Configuration Files", "config-yaml", priority=20) +def check_config_yaml(report): + from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH + + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + fallback = PROJECT_ROOT / "cli-config.yaml" + if fallback.exists(): + report.ok("cli-config.yaml exists (in project directory)") + return + + def _fix(r): + config_path.parent.mkdir(parents=True, exist_ok=True) + example = PROJECT_ROOT / "cli-config.yaml.example" + if example.exists(): + shutil.copy2(str(example), str(config_path)) + r.ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example") + else: + from hermes_cli.config import DEFAULT_CONFIG, save_config + save_config(DEFAULT_CONFIG) + r.ok(f"Created {_DHH}/config.yaml from defaults") + + report.warn( + "config.yaml not found", + "(using defaults)", + ) + report.add_issue( + f"{_DHH}/config.yaml missing", + fix_fn=_fix, + ) + return + + report.ok(f"{_DHH}/config.yaml exists") + _check_model_provider_config(report, config_path) + + +def _check_model_provider_config(report, config_path): + import yaml as _yaml + from hermes_cli.doctor import _DHH + + cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {} + model_section = cfg.get("model") or {} + provider_raw = (model_section.get("provider") or "").strip() + provider = provider_raw.lower() + default_model = (model_section.get("default") or model_section.get("model") or "").strip() + + known_providers: set[str] = set() + _resolve_auth_provider = None + _normalize_catalog_provider = None + _resolve_provider_full = None + + try: + from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider as _rap + known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"} + _resolve_auth_provider = _rap + except Exception: + pass + + try: + from hermes_cli.providers import normalize_provider as _ncp, resolve_provider_full as _rpf + _normalize_catalog_provider = _ncp + _resolve_provider_full = _rpf + except Exception: + pass + + custom_providers = [] + try: + from hermes_cli.config import get_compatible_custom_providers + custom_providers = get_compatible_custom_providers(cfg) or [] + except Exception: + pass + + user_providers = cfg.get("providers") + if isinstance(user_providers, dict): + known_providers.update(str(n).strip().lower() for n in user_providers if str(n).strip()) + for entry in custom_providers: + if isinstance(entry, dict): + n = str(entry.get("name") or "").strip() + if n: + known_providers.add("custom:" + n.lower().replace(" ", "-")) + + valid_ids = set(known_providers) + if _normalize_catalog_provider: + for kp in known_providers: + try: + valid_ids.add(_normalize_catalog_provider(kp)) + except Exception: + pass + + accepted = {provider} if provider else set() + runtime_provider = provider + if provider and _resolve_auth_provider and provider not in {"auto", "custom"}: + try: + runtime_provider = _resolve_auth_provider(provider) + accepted.add(runtime_provider) + except Exception: + pass + + catalog_provider = provider + if provider and _resolve_provider_full and provider not in {"auto", "custom"}: + pdef = _resolve_provider_full(provider, user_providers, custom_providers) + catalog_provider = pdef.id if pdef is not None else None + if catalog_provider: + accepted.add(catalog_provider) + + if provider and provider != "auto": + if catalog_provider is None or (known_providers and not (accepted & valid_ids)): + known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)" + report.fail( + f"model.provider '{provider_raw}' is not a recognised provider", + f"(known: {known_list})", + fix=f"run 'hermes config set model.provider ' — valid: {known_list}", + ) + + policy_id = str(runtime_provider or catalog_provider or "").strip().lower() + slug_ok_providers = {"openrouter", "auto", "kilocode", "opencode-zen", "huggingface", "lmstudio", "nous"} + slug_ok = policy_id in slug_ok_providers or policy_id == "custom" or policy_id.startswith("custom:") + if default_model and "/" in default_model and policy_id and not slug_ok: + report.warn( + f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'", + "(vendor-prefixed slugs belong to aggregators like openrouter)", + ) + report.add_issue( + f"model.default '{default_model}' is vendor-prefixed for provider '{provider_raw}' — " + "set model.provider to 'openrouter', or drop the vendor prefix" + ) + + if runtime_provider and runtime_provider not in ("auto", "custom"): + if runtime_provider == "openrouter": + from hermes_cli.config import get_env_value + configured = bool( + str(get_env_value("OPENROUTER_API_KEY") or "").strip() + or str(get_env_value("OPENAI_API_KEY") or "").strip() + ) + else: + from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status + pconfig = PROVIDER_REGISTRY.get(runtime_provider) + configured = True + if pconfig and getattr(pconfig, "auth_type", "") == "api_key": + status = get_auth_status(runtime_provider) or {} + configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key")) + if not configured: + report.fail( + f"model.provider '{runtime_provider}' is set but no API key is configured", + "(check ~/.hermes/.env or run 'hermes setup')", + fix=f"run 'hermes setup' or set the API key in {_DHH}/.env", + ) + + +@register("Configuration Files", "config-version", priority=30) +def check_config_version(report): + from hermes_cli.doctor import HERMES_HOME + + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + return + + from hermes_cli.config import check_config_version as _ccv, migrate_config + current_ver, latest_ver = _ccv() + if current_ver < latest_ver: + def _fix(r): + migrate_config(interactive=False, quiet=False) + r.ok("Config migrated to latest version") + + report.warn(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)") + report.add_issue( + "config.yaml is outdated — run 'hermes setup' to migrate", + fix_fn=_fix, + ) + else: + report.ok(f"Config version up to date (v{current_ver})") + + +@register("Configuration Files", "stale-root-keys", priority=40) +def check_stale_root_keys(report): + from hermes_cli.doctor import HERMES_HOME + + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + return + + import yaml + with open(config_path, encoding="utf-8") as f: + raw_config = yaml.safe_load(f) or {} + + stale = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)] + if not stale: + return + + def _fix(r): + raw_model = raw_config.get("model") + if isinstance(raw_model, dict): + model_section = raw_model + elif isinstance(raw_model, str) and raw_model.strip(): + model_section = {"default": raw_model.strip()} + raw_config["model"] = model_section + else: + model_section = {} + raw_config["model"] = model_section + for k in stale: + if not model_section.get(k): + model_section[k] = raw_config.pop(k) + else: + raw_config.pop(k) + from utils import atomic_yaml_write + atomic_yaml_write(config_path, raw_config) + r.ok("Migrated stale root-level keys into model section") + + report.warn( + f"Stale root-level config keys: {', '.join(stale)}", + "(should be under 'model:' section)", + ) + report.add_issue( + f"stale root-level keys {stale} in config.yaml", + fix_fn=_fix, + ) + + +@register("Configuration Files", "max-iterations-ghost", priority=50) +def check_max_iterations_ghost(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + config_path = HERMES_HOME / "config.yaml" + if not config_path.exists(): + return + + import yaml + from hermes_cli.config import load_env, remove_env_value + + with open(config_path, encoding="utf-8") as f: + raw_config = yaml.safe_load(f) or {} + + agent_cfg = raw_config.get("agent") + cfg_max_turns = agent_cfg.get("max_turns") if isinstance(agent_cfg, dict) else None + if cfg_max_turns is None: + cfg_max_turns = raw_config.get("max_turns") + + env_ghost = load_env().get("HERMES_MAX_ITERATIONS") + if not (cfg_max_turns is not None and env_ghost is not None + and str(cfg_max_turns).strip() != str(env_ghost).strip()): + return + + def _fix(r): + if remove_env_value("HERMES_MAX_ITERATIONS"): + r.ok( + "Removed stale HERMES_MAX_ITERATIONS from .env " + f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)" + ) + else: + raise RuntimeError( + f"could not remove HERMES_MAX_ITERATIONS from {_DHH}/.env — edit manually" + ) + + report.warn( + f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows agent.max_turns={cfg_max_turns} in config.yaml", + "(stale ghost from an earlier `hermes setup` run)", + ) + report.add_issue( + "stale HERMES_MAX_ITERATIONS in .env shadows config.yaml", + fix_fn=_fix, + ) + + +@register("Config Structure", "config-structure-validation", priority=10) +def check_config_structure(report): + from hermes_cli.config import validate_config_structure + config_issues = validate_config_structure() + if not config_issues: + return + for ci in config_issues: + if ci.severity == "error": + report.fail(ci.message) + else: + report.warn(ci.message) + for hint_line in ci.hint.splitlines(): + report.info(hint_line) + report.add_issue(ci.message) diff --git a/hermes_cli/doctor/checks/dep_mgmt.py b/hermes_cli/doctor/checks/dep_mgmt.py new file mode 100644 index 0000000000..18adfc1df0 --- /dev/null +++ b/hermes_cli/doctor/checks/dep_mgmt.py @@ -0,0 +1,85 @@ +"""Dependency management checks: venv integrity, uv, required packages.""" + +from __future__ import annotations + +import shutil + +from hermes_cli.doctor._registry import register +from hermes_cli.doctor.checks._helpers import python_install_cmd + + +@register("Virtual Environment Integrity", "venv-structure", priority=10) +def check_venv_integrity(report): + from hermes_cli.managed_uv import get_venv_path, resolve_uv + + venv_path = get_venv_path() + if not venv_path.exists(): + report.warn("Venv directory missing", "(will be recreated on next install/update)") + return + + has_uv = bool(resolve_uv() or shutil.which("uv")) + if not has_uv: + def _fix(r): + r.raw_print(" -> Attempting atomic venv recreation...") + from hermes_cli.managed_uv import recreate_venv_atomically, get_venv_path as _gvp + if recreate_venv_atomically(_gvp().parent, group="all"): + r.ok("Venv successfully recreated and swapped to uv-native state") + else: + raise RuntimeError("Recreation failed — please run the Hermes installer") + + report.fail( + "Legacy pip venv detected (uv missing)", + "(dependency management will fail)", + fix="run `hermes doctor --fix` to recreate", + fix_fn=_fix, + ) + else: + report.ok("Venv structure valid and uv-native") + + +@register("Dependency Management", "uv-available", priority=10) +def check_uv_available(report): + from hermes_cli.managed_uv import resolve_uv + uv_bin = resolve_uv() + if uv_bin: + report.ok(f"Managed uv available ({uv_bin})") + else: + path_uv = shutil.which("uv") + if path_uv: + report.ok(f"System uv available ({path_uv})") + else: + report.fail( + "uv is missing", + "(dependency installation will fail. Install uv via the Hermes installer, or `pkg install uv` on Termux)", + ) + + +@register("Required Packages", "required-packages", priority=10) +def check_required_packages(report): + required = [ + ("openai", "OpenAI SDK"), + ("rich", "Rich (terminal UI)"), + ("dotenv", "python-dotenv"), + ("yaml", "PyYAML"), + ("httpx", "HTTPX"), + ] + optional = [ + ("croniter", "Croniter (cron expressions)"), + ("telegram", "python-telegram-bot"), + ("discord", "discord.py"), + ] + install_cmd = python_install_cmd() + + for module, name in required: + try: + __import__(module) + report.ok(name) + except ImportError: + report.fail(name, "(missing)", fix=f"Install: {install_cmd} {module}") + + for module, name in optional: + try: + __import__(module) + report.ok(name, "(optional)") + except ImportError: + report.warn(name, "(optional, not installed)") diff --git a/hermes_cli/doctor/checks/directory_structure.py b/hermes_cli/doctor/checks/directory_structure.py new file mode 100644 index 0000000000..2fcc4cd439 --- /dev/null +++ b/hermes_cli/doctor/checks/directory_structure.py @@ -0,0 +1,155 @@ +"""Directory structure and state file checks.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +from hermes_cli.doctor._registry import register + + +@register("Directory Structure", "hermes-home", priority=10) +def check_hermes_home_dir(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + if not HERMES_HOME.exists(): + def _fix(r): + HERMES_HOME.mkdir(parents=True, exist_ok=True) + r.ok(f"Created {_DHH} directory") + report.warn(f"{_DHH} not found", "(will be created on first use)") + report.add_issue(f"{_DHH} directory missing", fix_fn=_fix) + else: + report.ok(f"{_DHH} directory exists") + + for subdir in ("cron", "sessions", "logs", "skills", "memories"): + p = HERMES_HOME / subdir + if not p.exists(): + def _fix(r, _p=p, _s=subdir): + _p.mkdir(parents=True, exist_ok=True) + r.ok(f"Created {_DHH}/{_s}/") + report.warn(f"{_DHH}/{subdir}/ not found", "(will be created on first use)") + report.add_issue(f"{_DHH}/{subdir}/ missing", fix_fn=_fix) + else: + report.ok(f"{_DHH}/{subdir}/ exists") + + +@register("Directory Structure", "soul-md", priority=20) +def check_soul_md(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + soul_path = HERMES_HOME / "SOUL.md" + if soul_path.exists(): + content = soul_path.read_text(encoding="utf-8").strip() + lines = [l for l in content.splitlines() + if l.strip() and not l.strip().startswith(("", "#"))] + if lines: + report.ok(f"{_DHH}/SOUL.md exists (persona configured)") + else: + report.info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality") + else: + def _fix(r): + soul_path.parent.mkdir(parents=True, exist_ok=True) + soul_path.write_text( + "# Hermes Agent Persona\n\n" + "\n\n" + "You are Hermes, a helpful AI assistant.\n", + encoding="utf-8", + ) + r.ok(f"Created {_DHH}/SOUL.md with basic template") + + report.warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)") + report.add_issue(f"{_DHH}/SOUL.md missing", fix_fn=_fix) + + +@register("Directory Structure", "memories-dir", priority=30) +def check_memories_dir(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + memories_dir = HERMES_HOME / "memories" + if not memories_dir.exists(): + def _fix(r): + memories_dir.mkdir(parents=True, exist_ok=True) + r.ok(f"Created {_DHH}/memories/") + report.warn(f"{_DHH}/memories/ not found", "(will be created on first use)") + report.add_issue(f"{_DHH}/memories/ missing", fix_fn=_fix) + return + + report.ok(f"{_DHH}/memories/ directory exists") + for fname in ("MEMORY.md", "USER.md"): + fpath = memories_dir / fname + if fpath.exists(): + size = len(fpath.read_text(encoding="utf-8").strip()) + report.ok(f"{fname} exists ({size} chars)") + else: + report.info(f"{fname} not created yet (will be created when the agent first writes a memory)") + + +@register("Directory Structure", "state-db", priority=40) +def check_state_db(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + db_path = HERMES_HOME / "state.db" + if not db_path.exists(): + report.info(f"{_DHH}/state.db not created yet (will be created on first session)") + return + + try: + conn = sqlite3.connect(str(db_path)) + count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] + conn.close() + report.ok(f"{_DHH}/state.db exists ({count} sessions)") + except Exception as e: + from hermes_state import is_malformed_db_error, repair_state_db_schema + if is_malformed_db_error(e): + def _fix(r): + db_report = repair_state_db_schema(db_path) + if not db_report.get("repaired"): + raise RuntimeError( + f"{db_report.get('error')}; backup at {db_report.get('backup_path')}" + ) + try: + conn = sqlite3.connect(str(db_path)) + count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0] + conn.close() + except Exception: + count = "?" + backup = Path(db_report["backup_path"]).name if db_report.get("backup_path") else "n/a" + r.ok( + f"Repaired state.db schema ({count} sessions recovered)", + f"(strategy: {db_report.get('strategy')}; backup: {backup})", + ) + + report.warn( + f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)", + f"({e})", + ) + report.add_issue("state.db schema malformed", fix_fn=_fix) + else: + report.warn(f"{_DHH}/state.db exists but has issues: {e}") + + +@register("Directory Structure", "wal-file", priority=50) +def check_wal_file(report): + from hermes_cli.doctor import HERMES_HOME, _DHH + + db_path = HERMES_HOME / "state.db" + wal_path = HERMES_HOME / "state.db-wal" + if not wal_path.exists() or not db_path.exists(): + return + + wal_size = wal_path.stat().st_size + if wal_size > 50 * 1024 * 1024: + def _fix(r): + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA wal_checkpoint(PASSIVE)") + conn.close() + new_size = wal_path.stat().st_size if wal_path.exists() else 0 + r.ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)") + + report.warn( + f"WAL file is large ({wal_size // (1024 * 1024)} MB)", + "(may indicate missed checkpoints)", + ) + report.add_issue("large WAL file — checkpoint needed", fix_fn=_fix) + elif wal_size > 10 * 1024 * 1024: + report.info(f"WAL file is {wal_size // (1024 * 1024)} MB (normal for active sessions)") diff --git a/hermes_cli/doctor/checks/external_tools.py b/hermes_cli/doctor/checks/external_tools.py new file mode 100644 index 0000000000..8cef14c244 --- /dev/null +++ b/hermes_cli/doctor/checks/external_tools.py @@ -0,0 +1,274 @@ +"""External tool checks: git, ripgrep, docker, ssh, daytona, node, npm audit.""" + +from __future__ import annotations + +import os +import subprocess +import sys + +from hermes_cli.doctor._registry import register +from hermes_cli.doctor.checks._helpers import ( + _safe_which, + is_termux, + system_package_install_cmd, + termux_browser_setup_steps, + termux_install_all_fallback_notes, +) + + +@register("External Tools", "git", priority=10) +def check_git(report): + if _safe_which("git"): + report.ok("git") + else: + report.warn("git not found", "(hermes update cannot work)") + + +@register("External Tools", "ripgrep", priority=20) +def check_ripgrep(report): + if _safe_which("rg"): + report.ok("ripgrep (rg)", "(faster file search)") + else: + report.warn("ripgrep (rg) not found", "(file search uses grep fallback)") + report.info(f"Install for faster search: {system_package_install_cmd('ripgrep')}") + + +@register("External Tools", "docker", priority=30) +def check_docker(report): + from hermes_cli.doctor import PROJECT_ROOT + + terminal_env = os.getenv("TERMINAL_ENV", "local") + running_in_container = False + try: + from hermes_constants import is_container as _is_container + running_in_container = _is_container() + except Exception: + pass + + if running_in_container and terminal_env != "docker": + report.info( + "Running inside a container — using local terminal backend " + "(docker-in-docker is not configured by default)" + ) + return + + if terminal_env == "docker": + if not _safe_which("docker"): + report.fail( + "docker not found", + "(required for TERMINAL_ENV=docker)", + fix="Install Docker or change TERMINAL_ENV", + ) + return + try: + res = subprocess.run(["docker", "info"], capture_output=True, timeout=10) + except subprocess.TimeoutExpired: + res = None + if res is not None and res.returncode == 0: + report.ok("docker", "(daemon running)") + else: + report.fail("docker daemon not running", "", fix="Start Docker daemon") + elif _safe_which("docker"): + report.ok("docker", "(optional)") + elif is_termux(): + report.info("Docker backend is not available inside Termux (expected on Android)") + else: + report.warn("docker not found", "(optional)") + + +@register("External Tools", "ssh-backend", priority=40) +def check_ssh_backend(report): + terminal_env = os.getenv("TERMINAL_ENV", "local") + if terminal_env != "ssh": + return + + ssh_host = os.getenv("TERMINAL_SSH_HOST") + if not ssh_host: + report.fail( + "TERMINAL_SSH_HOST not set", + "(required for TERMINAL_ENV=ssh)", + fix="Set TERMINAL_SSH_HOST in .env", + ) + return + + ssh_user = os.getenv("TERMINAL_SSH_USER") + ssh_port = os.getenv("TERMINAL_SSH_PORT") + ssh_key = os.getenv("TERMINAL_SSH_KEY") + target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host + cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"] + if ssh_port: + cmd += ["-p", ssh_port] + if ssh_key: + cmd += ["-i", os.path.expanduser(ssh_key)] + cmd += [target, "echo ok"] + + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + except subprocess.TimeoutExpired: + res = None + + if res is not None and res.returncode == 0: + report.ok(f"SSH connection to {ssh_host}") + else: + report.fail( + f"SSH connection to {ssh_host}", "", + fix=f"Check SSH configuration for {ssh_host}", + ) + + +@register("External Tools", "daytona-backend", priority=50) +def check_daytona_backend(report): + if os.getenv("TERMINAL_ENV", "local") != "daytona": + return + + if not os.getenv("DAYTONA_API_KEY"): + report.fail( + "DAYTONA_API_KEY not set", + "(required for TERMINAL_ENV=daytona)", + fix="Set DAYTONA_API_KEY environment variable", + ) + else: + report.ok("Daytona API key", "(configured)") + + try: + from daytona import Daytona # noqa: F401 + report.ok("daytona SDK", "(installed)") + except ImportError: + report.fail( + "daytona SDK not installed", + "(pip install daytona)", + fix="Install daytona SDK: pip install daytona", + ) + + +@register("External Tools", "node-and-browser", priority=60) +def check_node_and_browser(report): + from hermes_cli.doctor import PROJECT_ROOT + + if not _safe_which("node"): + if is_termux(): + report.info("Node.js not found (browser tools are optional in the tested Termux path)") + report.info("Install Node.js on Termux with: pkg install nodejs") + report.info("Termux browser setup:") + for step in termux_browser_setup_steps(node_installed=False): + report.info(step) + else: + report.warn("Node.js not found", "(optional, needed for browser tools)") + return + + report.ok("Node.js") + + agent_browser_ok = False + if (PROJECT_ROOT / "node_modules" / "agent-browser").exists(): + report.ok("agent-browser (Node.js)", "(browser automation)") + agent_browser_ok = True + elif _safe_which("agent-browser"): + report.ok("agent-browser", "(browser automation)") + agent_browser_ok = True + elif is_termux(): + report.info("agent-browser is not installed (expected in the tested Termux path)") + report.info("Install it manually later with: npm install -g agent-browser && agent-browser install") + report.info("Termux browser setup:") + for step in termux_browser_setup_steps(node_installed=True): + report.info(step) + else: + report.warn("agent-browser not installed", "(run: npm install)") + + if agent_browser_ok and not is_termux(): + from tools.browser_tool import ( + _chromium_installed, + _is_camofox_mode, + _get_cloud_provider, + _get_cdp_override, + _using_lightpanda_engine, + ) + skip = ( + _is_camofox_mode() + or bool(_get_cdp_override()) + or _get_cloud_provider() is not None + or _using_lightpanda_engine() + ) + if not skip: + if _chromium_installed(): + report.ok("Playwright Chromium", "(browser engine)") + else: + report.warn( + "Playwright Chromium not installed", + "(browser_* tools will be hidden from the agent)", + ) + install_cmd = ( + f"cd {PROJECT_ROOT} && npx playwright install chromium" + if sys.platform == "win32" + else f"cd {PROJECT_ROOT} && npx playwright install --with-deps chromium" + ) + report.info(f"Install with: {install_cmd}") + + +@register("External Tools", "npm-audit", priority=70) +def check_npm_audit(report): + import json + from hermes_cli.doctor import PROJECT_ROOT + + npm_bin = _safe_which("npm") + if not npm_bin: + return + + audit_targets = [ + (PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]), + (PROJECT_ROOT, "web workspace", ["--workspace", "web"]), + (PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]), + (PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []), + ] + for npm_dir, label, extra in audit_targets: + check_dir = PROJECT_ROOT if extra else npm_dir + if not (check_dir / "node_modules").exists(): + continue + # best-effort: failures are silently ignored + try: + res = subprocess.run( + [npm_bin, "audit", "--json", *extra], + cwd=str(npm_dir), + capture_output=True, text=True, timeout=30, + ) + data = json.loads(res.stdout) if res.stdout.strip() else {} + except Exception: + continue + + vc = data.get("metadata", {}).get("vulnerabilities", {}) + critical = vc.get("critical", 0) + high = vc.get("high", 0) + moderate = vc.get("moderate", 0) + total = critical + high + moderate + + if extra and extra[0] == "--workspace": + fix_cmd = f"cd {npm_dir} && npm audit fix {' '.join(extra)}" + elif extra == ["--workspaces=false"]: + fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false" + else: + fix_cmd = f"cd {npm_dir} && npm audit fix" + + if total == 0: + report.ok(f"{label} deps", "(no known vulnerabilities)") + elif critical > 0 or high > 0: + report.warn( + f"{label} deps", + f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})", + ) + report.add_issue( + f"{label} has {total} npm " + f"{'vulnerability' if total == 1 else 'vulnerabilities'}" + ) + else: + report.ok( + f"{label} deps", + f"({moderate} moderate {'vulnerability' if moderate == 1 else 'vulnerabilities'})", + ) + + +@register("External Tools", "termux-notes", priority=80) +def check_termux_notes(report): + if not is_termux(): + return + report.info("Termux compatibility fallbacks:") + for note in termux_install_all_fallback_notes(): + report.info(note) diff --git a/hermes_cli/doctor/checks/gateway_service.py b/hermes_cli/doctor/checks/gateway_service.py new file mode 100644 index 0000000000..43a1882bde --- /dev/null +++ b/hermes_cli/doctor/checks/gateway_service.py @@ -0,0 +1,54 @@ +"""Gateway service checks.""" + +from __future__ import annotations + +import os + +from hermes_cli.doctor._registry import register + + +@register("Gateway Service", "linger-check", priority=10) +def check_gateway_service_linger(report): + from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux + from hermes_cli.service_manager import detect_service_manager + + if not is_linux() or detect_service_manager() == "s6": + return + + unit_path = get_systemd_unit_path() + if not unit_path.exists(): + return + + linger_enabled, linger_detail = get_systemd_linger_status() + if linger_enabled is True: + report.ok("Systemd linger enabled", "(gateway service survives logout)") + elif linger_enabled is False: + report.warn("Systemd linger disabled", "(gateway may stop after logout)") + report.info("Run: sudo loginctl enable-linger $USER") + report.add_issue("Enable linger for the gateway user service: sudo loginctl enable-linger $USER") + else: + report.warn("Could not verify systemd linger", f"({linger_detail})") + + +@register("s6 Supervision", "s6-supervision", priority=10) +def check_s6_supervision(report): + from hermes_cli.service_manager import S6ServiceManager, detect_service_manager + + if detect_service_manager() != "s6": + return + + mgr = S6ServiceManager() + for static in ("main-hermes", "dashboard"): + if mgr.is_running(static): + report.ok(f"{static}: up") + else: + report.info(f"{static}: down (expected if not enabled via env)") + + profiles = mgr.list_profile_gateways() + if not profiles: + report.info("No per-profile gateways registered yet — create one with `hermes profile create `") + return + + up = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}")) + suffix = f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else "" + report.ok(f"Per-profile gateways: {up}/{len(profiles)} supervised up{suffix}") diff --git a/hermes_cli/doctor/checks/github_check.py b/hermes_cli/doctor/checks/github_check.py new file mode 100644 index 0000000000..d39305de89 --- /dev/null +++ b/hermes_cli/doctor/checks/github_check.py @@ -0,0 +1,36 @@ +"""GitHub auth check.""" + +from __future__ import annotations + +import subprocess + +from hermes_cli.doctor._registry import register + + +@register("Skills Hub", priority=20) +def check_github_auth(report): + """Check GitHub authentication status.""" + from hermes_cli.doctor import _DHH + + try: + from hermes_cli.config import get_env_value + except Exception: + return + + def _gh_authenticated() -> bool: + try: + result = subprocess.run( + ["gh", "auth", "status", "--json", "authenticated"], + capture_output=True, timeout=10, + ) + return result.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") + if github_token: + report.ok("GitHub token configured (authenticated API access)") + elif _gh_authenticated(): + report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)") + else: + report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") diff --git a/hermes_cli/doctor/checks/memory_provider.py b/hermes_cli/doctor/checks/memory_provider.py new file mode 100644 index 0000000000..bb393464b6 --- /dev/null +++ b/hermes_cli/doctor/checks/memory_provider.py @@ -0,0 +1,73 @@ +"""Memory provider checks.""" + +from __future__ import annotations + +from hermes_cli.doctor._registry import register + + +@register("Memory Provider", "memory-provider", priority=10) +def check_memory_provider(report): + from hermes_cli.doctor import HERMES_HOME + + provider = "" + import yaml as _yaml + cfg_path = HERMES_HOME / "config.yaml" + if cfg_path.exists(): + with open(cfg_path, encoding="utf-8") as f: + raw = _yaml.safe_load(f) or {} + provider = (raw.get("memory") or {}).get("provider", "") + + if not provider: + report.ok("Built-in memory active", "(no external provider configured — this is fine)") + elif provider == "honcho": + _check_honcho(report) + elif provider == "mem0": + _check_mem0(report) + else: + _check_generic(report, provider) + + +def _check_honcho(report): + from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path + hcfg = HonchoClientConfig.from_global_config() + cfg_path = resolve_config_path() + + if not cfg_path.exists(): + if hcfg.api_key or hcfg.base_url: + report.ok("Honcho configured via environment variables", + f"config file {cfg_path} not found, using HONCHO_API_KEY env var") + else: + report.warn("Honcho config not found", "run: hermes memory setup") + elif not hcfg.enabled: + report.info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)") + elif not (hcfg.api_key or hcfg.base_url): + report.fail("Honcho API key or base URL not set", "run: hermes memory setup", + fix="No Honcho API key — run 'hermes memory setup'") + else: + from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client + reset_honcho_client() + get_honcho_client(hcfg) + report.ok("Honcho connected", + f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}") + + +def _check_mem0(report): + from plugins.memory.mem0 import _load_config as _lc + cfg = _lc() + if cfg.get("api_key"): + report.ok("Mem0 API key configured") + report.info(f"user_id={cfg.get('user_id', '?')} agent_id={cfg.get('agent_id', '?')}") + else: + report.fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)", + fix="Mem0 is set as memory provider but API key is missing") + + +def _check_generic(report, provider_name): + from plugins.memory import load_memory_provider + p = load_memory_provider(provider_name) + if p and p.is_available(): + report.ok(f"{provider_name} provider active") + elif p: + report.warn(f"{provider_name} configured but not available", "run: hermes memory status") + else: + report.warn(f"{provider_name} plugin not found", "run: hermes memory setup") diff --git a/hermes_cli/doctor/checks/profiles.py b/hermes_cli/doctor/checks/profiles.py new file mode 100644 index 0000000000..c0f534f09d --- /dev/null +++ b/hermes_cli/doctor/checks/profiles.py @@ -0,0 +1,42 @@ +"""Named profiles check.""" + +from __future__ import annotations + +import re + +from hermes_cli.doctor._registry import register + + +@register("Profiles", "named-profiles", priority=10) +def check_profiles(report): + from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists + + named = [p for p in list_profiles() if not p.is_default] + if not named: + return + + report.ok(f"{len(named)} profile(s) found") + wrapper_dir = _get_wrapper_dir() + for p in named: + parts = [] + if p.gateway_running: + parts.append("gateway running") + if p.model: + parts.append(p.model[:30]) + if not (p.path / "config.yaml").exists(): + parts.append("⚠ missing config") + if not (p.path / ".env").exists(): + parts.append("no .env") + if not (wrapper_dir / p.name).exists(): + parts.append("no alias") + report.ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}") + + if wrapper_dir.is_dir(): + for wrapper in wrapper_dir.iterdir(): + if not wrapper.is_file(): + continue + content = wrapper.read_text() + if "hermes -p" in content: + m = re.search(r"hermes -p (\S+)", content) + if m and not profile_exists(m.group(1)): + report.warn(f"Orphan alias: {wrapper.name} → profile '{m.group(1)}' no longer exists") diff --git a/hermes_cli/doctor/checks/python_env.py b/hermes_cli/doctor/checks/python_env.py new file mode 100644 index 0000000000..260eff69ea --- /dev/null +++ b/hermes_cli/doctor/checks/python_env.py @@ -0,0 +1,71 @@ +"""Python environment checks.""" + +from __future__ import annotations + +import sys + +from hermes_cli.doctor._registry import register + + +@register("Python Environment", "python-version", priority=10) +def check_python_version(report): + py = sys.version_info + if py >= (3, 11): + report.ok(f"Python {py.major}.{py.minor}.{py.micro}") + elif py >= (3, 10): + report.ok(f"Python {py.major}.{py.minor}.{py.micro}") + report.warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)") + else: + report.fail( + f"Python {py.major}.{py.minor}.{py.micro}", + "(3.10+ required)", + fix="Upgrade Python to 3.10+", + ) + + +@register("Python Environment", "venv-active", priority=20) +def check_venv_active(report): + if sys.prefix != sys.base_prefix: + report.ok("Virtual environment active") + else: + report.warn("Not in virtual environment", "(recommended)") + + +@register("Python Environment", "version-consistency", priority=30) +def check_version_consistency(report): + """Verify pyproject.toml version matches hermes_cli.__version__.""" + from hermes_cli.doctor import PROJECT_ROOT + from hermes_cli import __version__ as init_version + + pyproject = PROJECT_ROOT / "pyproject.toml" + try: + text = pyproject.read_text(encoding="utf-8") + except OSError: + return # Installed wheel — nothing to cross-check + + in_project = False + pyproject_version = None + for raw in text.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + in_project = line == "[project]" + continue + if in_project and line.startswith("version") and "=" in line: + value = line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"\'") + pyproject_version = value or None + break + + if pyproject_version is None: + return + + if pyproject_version == init_version: + report.ok("Version files consistent", f"({init_version})") + else: + report.fail( + "Version mismatch between source files", + f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})", + fix=( + "Re-sync version files (e.g. run 'hermes update', or set " + "hermes_cli/__init__.py __version__ to match pyproject.toml)" + ), + ) diff --git a/hermes_cli/doctor/checks/security.py b/hermes_cli/doctor/checks/security.py new file mode 100644 index 0000000000..ec19640d81 --- /dev/null +++ b/hermes_cli/doctor/checks/security.py @@ -0,0 +1,47 @@ +"""Security advisory checks.""" + +from __future__ import annotations + +from hermes_cli.doctor._registry import register + + +@register("Security Advisories", "security-advisories", priority=10) +def check_security_advisories(report): + from hermes_cli.security_advisories import ( + detect_compromised, + filter_unacked, + full_remediation_text, + get_acked_ids, + ) + + all_hits = detect_compromised() + fresh_hits = filter_unacked(all_hits) + + if not fresh_hits: + report.ok("No active security advisories") + return + + for hit in fresh_hits: + report.fail( + f"{hit.advisory.title}", + f"({hit.package}=={hit.installed_version})", + ) + for line in full_remediation_text(hit): + if line: + report.raw_print(f" {report.color(line, report.YELLOW)}") + else: + report.raw_print() + report.add_issue( + f"Resolve security advisory {hit.advisory.id}: " + f"uninstall {hit.package}=={hit.installed_version} and " + f"rotate credentials, then run " + f"`hermes doctor --ack {hit.advisory.id}`." + ) + + acked_ids = get_acked_ids() + for h in all_hits: + if h.advisory.id in acked_ids: + report.warn( + f"{h.package}=={h.installed_version} still installed " + f"(advisory {h.advisory.id} acknowledged)", + ) diff --git a/hermes_cli/doctor/checks/skills_hub.py b/hermes_cli/doctor/checks/skills_hub.py new file mode 100644 index 0000000000..9a10ace5d1 --- /dev/null +++ b/hermes_cli/doctor/checks/skills_hub.py @@ -0,0 +1,56 @@ +"""Skills Hub and GitHub auth checks.""" + +from __future__ import annotations + +import json +import subprocess + +from hermes_cli.doctor._registry import register + + +@register("Skills Hub", "skills-hub-dir", priority=10) +def check_skills_hub(report): + from hermes_cli.doctor import HERMES_HOME + + hub_dir = HERMES_HOME / "skills" / ".hub" + if not hub_dir.exists(): + report.warn("Skills Hub directory not initialized", "(run: hermes skills list)") + return + + report.ok("Skills Hub directory exists") + lock_file = hub_dir / "lock.json" + if lock_file.exists(): + lock_data = json.loads(lock_file.read_text()) + count = len(lock_data.get("installed", {})) + report.ok(f"Lock file OK ({count} hub-installed skill(s))") + else: + report.warn("Lock file", "(corrupted or unreadable)") + + quarantine = hub_dir / "quarantine" + q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0 + if q_count > 0: + report.warn(f"{q_count} skill(s) in quarantine", "(pending review)") + + +@register("Skills Hub", "github-auth", priority=20) +def check_github_auth(report): + from hermes_cli.doctor import _DHH + from hermes_cli.config import get_env_value + + def _gh_authenticated() -> bool: + try: + res = subprocess.run( + ["gh", "auth", "status", "--json", "authenticated"], + capture_output=True, timeout=10, + ) + return res.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN") + if github_token: + report.ok("GitHub token configured (authenticated API access)") + elif _gh_authenticated(): + report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)") + else: + report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)") diff --git a/hermes_cli/doctor/checks/tool_availability.py b/hermes_cli/doctor/checks/tool_availability.py new file mode 100644 index 0000000000..4ddf6572e3 --- /dev/null +++ b/hermes_cli/doctor/checks/tool_availability.py @@ -0,0 +1,78 @@ +"""Tool availability checks using model_tools.""" + +from __future__ import annotations + +import os + +from hermes_cli.doctor._registry import register + + +def _is_kanban_worker_env_gate(item: dict) -> bool: + if item.get("name") != "kanban": + return False + if os.environ.get("HERMES_KANBAN_TASK"): + return False + tools = item.get("tools") or [] + return bool(tools) and all(str(t).startswith("kanban_") for t in tools) + + +def _honcho_is_configured_for_doctor() -> bool: + try: + from plugins.memory.honcho.client import HonchoClientConfig + cfg = HonchoClientConfig.from_global_config() + return bool(cfg.enabled and (cfg.api_key or cfg.base_url)) + except Exception: + return False + + +def _doctor_tool_availability_detail(toolset: str) -> str: + if toolset == "kanban" and not os.environ.get("HERMES_KANBAN_TASK"): + return "(runtime-gated; loaded only for dispatcher-spawned workers)" + return "" + + +def _apply_doctor_tool_availability_overrides(available, unavailable): + """Adjust runtime-gated tool availability for doctor diagnostics. + + Indirects through the hermes_cli.doctor module so that monkeypatching + doctor._honcho_is_configured_for_doctor in tests works as expected. + """ + import hermes_cli.doctor as _doctor_mod + updated = list(available) + remaining = [] + for item in unavailable: + name = item.get("name") + if _is_kanban_worker_env_gate(item): + if "kanban" not in updated: + updated.append("kanban") + continue + if name == "honcho" and _doctor_mod._honcho_is_configured_for_doctor(): + if "honcho" not in updated: + updated.append("honcho") + continue + remaining.append(item) + return updated, remaining + + +@register("Tool Availability", "tool-availability", priority=10) +def check_tool_availability(report): + from hermes_cli.doctor import PROJECT_ROOT + import sys + sys.path.insert(0, str(PROJECT_ROOT)) + from model_tools import check_tool_availability as _cta, TOOLSET_REQUIREMENTS + + available, unavailable = _apply_doctor_tool_availability_overrides(*_cta()) + + for tid in available: + info = TOOLSET_REQUIREMENTS.get(tid, {}) + report.ok(info.get("name", tid), _doctor_tool_availability_detail(tid)) + + for item in unavailable: + env_vars = item.get("missing_vars") or item.get("env_vars") or [] + if env_vars: + report.warn(item["name"], f"(missing {', '.join(env_vars)})") + else: + report.warn(item["name"], "(system dependency not met)") + + if any(u.get("missing_vars") or u.get("env_vars") for u in unavailable): + report.add_issue("Run 'hermes setup' to configure missing API keys for full tool access") diff --git a/hermes_cli/doctor/checks/xai_retirement.py b/hermes_cli/doctor/checks/xai_retirement.py new file mode 100644 index 0000000000..3c44bc3866 --- /dev/null +++ b/hermes_cli/doctor/checks/xai_retirement.py @@ -0,0 +1,24 @@ +"""xAI model retirement check.""" + +from __future__ import annotations + +from hermes_cli.doctor._registry import register + + +@register("xAI Model Retirement (May 15, 2026)", "xai-retirement", priority=10) +def check_xai_retirement(report): + from hermes_cli.config import load_config + from hermes_cli.xai_retirement import MIGRATION_GUIDE_URL, find_retired_xai_refs, format_issue + + cfg = load_config() + retired_refs = find_retired_xai_refs(cfg) + if not retired_refs: + report.ok("No retired xAI models in config") + return + for ref in retired_refs: + report.warn(format_issue(ref)) + report.info(f"Migration guide: {MIGRATION_GUIDE_URL}") + report.add_issue( + f"Update {len(retired_refs)} retired xAI model reference(s) " + f"in config.yaml — see {MIGRATION_GUIDE_URL}" + ) diff --git a/hermes_cli/dump.py b/hermes_cli/dump.py index 16d6f6069f..b04f96636b 100644 --- a/hermes_cli/dump.py +++ b/hermes_cli/dump.py @@ -13,9 +13,9 @@ import subprocess import sys from pathlib import Path -from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config +from hermes_cli.config import get_hermes_home, get_env_path, load_config from hermes_cli.env_loader import load_hermes_dotenv -from hermes_constants import display_hermes_home +from hermes_constants import display_hermes_home, get_hermes_source_root from agent.skill_utils import is_excluded_skill_path @@ -224,10 +224,10 @@ def run_dump(args): env_path = get_env_path() load_hermes_dotenv( hermes_home=env_path.parent, - project_env=get_project_root() / ".env", + project_env=get_hermes_source_root() / ".env", ) - project_root = get_project_root() + project_root = get_hermes_source_root() hermes_home = get_hermes_home() try: diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 5ff7425918..f6993e821e 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -15,7 +15,8 @@ import textwrap from dataclasses import dataclass from pathlib import Path -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() from gateway.status import terminate_pid from gateway.restart import ( @@ -2139,34 +2140,15 @@ def get_launchd_plist_path() -> Path: def _detect_venv_dir() -> Path | None: """Detect the active virtualenv directory. - Checks ``sys.prefix`` first (works regardless of the directory name), - then ``VIRTUAL_ENV`` env var (covers uv-managed environments where - sys.prefix == sys.base_prefix), then falls back to probing common - directory names under PROJECT_ROOT. - Returns ``None`` when no virtualenv can be found. + Delegates to ``get_venv_path()`` from ``hermes_cli.managed_uv``, + which is the single source of truth for locating the project venv. + Returns ``None`` only when the resolved path doesn't exist on disk + (pre-install state). """ - # If we're running inside a virtualenv, sys.prefix points to it. - if sys.prefix != sys.base_prefix: - venv = Path(sys.prefix) - if venv.is_dir(): - return venv + from hermes_cli.managed_uv import get_venv_path + p = get_venv_path() + return p if p.is_dir() else None - # uv and some other tools set VIRTUAL_ENV without changing sys.prefix. - # This catches `uv run` where sys.prefix == sys.base_prefix but the - # environment IS a venv. (#8620) - _virtual_env = os.environ.get("VIRTUAL_ENV") - if _virtual_env: - venv = Path(_virtual_env) - if venv.is_dir(): - return venv - - # Fallback: check common virtualenv directory names under the project root. - for candidate in (".venv", "venv"): - venv = PROJECT_ROOT / candidate - if venv.is_dir(): - return venv - - return None def get_python_path() -> str: @@ -2355,7 +2337,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None) python_path = get_python_path() working_dir = _stable_service_working_dir() detected_venv = _detect_venv_dir() - venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + from hermes_cli.managed_uv import get_venv_path as _gvp + venv_dir = str(detected_venv) if detected_venv else str(_gvp()) path_entries = _build_service_path_dirs() resolved_node = shutil.which("node") @@ -3193,7 +3176,8 @@ def generate_launchd_plist() -> str: # the systemd unit), then capture the user's full shell PATH so every # user-installed tool (node, ffmpeg, …) is reachable. detected_venv = _detect_venv_dir() - venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv") + from hermes_cli.managed_uv import get_venv_path as _gvp + venv_dir = str(detected_venv) if detected_venv else str(_gvp()) # Resolve the directory containing the node binary (e.g. Homebrew, nvm) # so it's explicitly in PATH even if the user's shell PATH changes later. priority_dirs = _build_service_path_dirs() diff --git a/hermes_cli/gateway_windows.py b/hermes_cli/gateway_windows.py index 08c7d8c019..38e9ad64a1 100644 --- a/hermes_cli/gateway_windows.py +++ b/hermes_cli/gateway_windows.py @@ -183,7 +183,8 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None if extra_args: args.extend(extra_args) params = subprocess.list2cmdline(args) - cwd = str(Path(__file__).resolve().parent.parent) + from hermes_constants import get_hermes_source_root + cwd = str(get_hermes_source_root()) elevated_python = _derive_venv_pythonw(sys.executable) try: result = ctypes.windll.shell32.ShellExecuteW( diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0188d38072..b442f31b76 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -320,7 +320,8 @@ def _require_tty(command_name: str) -> None: # Add project root to path -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() sys.path.insert(0, str(PROJECT_ROOT)) @@ -4516,10 +4517,10 @@ def _nixos_build_env() -> dict[str, str] | None: return None # Tier 1: fast path — hermes venv python3, no nix-shell overhead - for venv_name in ("venv", ".venv"): - venv_python = PROJECT_ROOT / venv_name / "bin" / "python3" - if venv_python.exists(): - return {**os.environ, "PYTHON": str(venv_python)} + from hermes_cli.managed_uv import get_venv_path as _gvp + venv_python = _gvp() / "bin" / "python3" + if venv_python.exists(): + return {**os.environ, "PYTHON": str(venv_python)} # Tier 2: nix-shell fallback — resolves the absolute python3 path once. # Slower (~2–5 s for the nix-shell eval) but always works, even without @@ -5778,14 +5779,14 @@ def _update_via_zip(args): # individually so update does not silently strip working capabilities. print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, get_pip_cmd, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() uv_bin = ensure_uv() if not uv_bin: - uv_bin = _ensure_uv_for_termux(get_pip_cmd()) + uv_bin = _ensure_uv_for_termux() if uv_bin: _install_python_dependencies_with_optional_fallback() @@ -7014,7 +7015,9 @@ def _install_python_dependencies_with_optional_fallback( copies (Windows blocks REPLACE on a running .exe but allows RENAME). See ``_quarantine_running_hermes_exe`` for the rationale. - Uses the authoritative `get_pip_cmd()` for dependency installation. + Uses ``get_pip_cmd()`` for dependency installation — this function builds + non-standard install commands (``-e .[group]``, ``--user``, etc.) that + don't fit the ``pip_install()`` helper's signature. """ from hermes_cli.managed_uv import get_pip_cmd @@ -7287,7 +7290,6 @@ def _is_android_python() -> bool: def _install_psutil_android_compat( - install_cmd_prefix: list[str], *, env: dict[str, str] | None = None, ) -> None: @@ -7316,13 +7318,14 @@ def _install_psutil_android_compat( urllib.request.urlretrieve(PSUTIL_URL, archive) src_root = prepare_patched_psutil_sdist(archive, tmp_path) + from hermes_cli.managed_uv import get_pip_cmd as _gpc _run_install_with_heartbeat( - install_cmd_prefix + ["install", "--no-build-isolation", str(src_root)], + _gpc() + ["install", "--no-build-isolation", str(src_root)], env=env, ) -def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: +def _ensure_uv_for_termux() -> str | None: """Best-effort uv bootstrap on Termux for faster update installs. The normal path (``ensure_uv()`` in managed_uv) installs the managed @@ -7339,7 +7342,8 @@ def _ensure_uv_for_termux(pip_cmd: list[str]) -> str | None: return None try: print(" → Termux detected: trying to install uv for faster dependency updates...") - subprocess.run(pip_cmd + ["install", "uv"], cwd=PROJECT_ROOT, check=False) + from hermes_cli.managed_uv import get_pip_cmd as _gpc + subprocess.run(_gpc() + ["install", "uv"], cwd=PROJECT_ROOT, check=False) except Exception: pass # After pip install, check managed path first, then PATH @@ -7993,59 +7997,53 @@ def cmd_update(args): def _cmd_update_pip(args): - """Update Hermes via pip (for PyPI installs).""" + """Update Hermes via its managed install path. + + - ``uv tool install`` / ``uv tool upgrade`` path: use ``uv tool upgrade hermes-agent``. + - ``pipx`` path: use ``pipx upgrade hermes-agent``. + - Direct git-checkout (standard install.sh path): delegate to the git-based + ``_cmd_update_impl`` which pulls, reinstalls deps, etc. + - Plain ``pip install hermes-agent`` from PyPI is no longer supported. + Users on that path should reinstall via the official installer. + """ from hermes_cli import __version__ from hermes_cli.config import is_uv_tool_install print(f"→ Current version: {__version__}") - print("→ Checking PyPI for updates...") - from hermes_cli.managed_uv import ensure_uv, get_pip_cmd, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current before using it. update_managed_uv() uv = ensure_uv() - in_venv = sys.prefix != sys.base_prefix - # pipx-managed installs live under .../pipx/venvs//... pipx_managed = "pipx" in sys.prefix.split(os.sep) pipx = shutil.which("pipx") if pipx_managed else None - # Only the ``uv pip install`` path inside a venv needs VIRTUAL_ENV - # exported (uv refuses to install without it when the launcher shim - # didn't activate the venv). ``uv tool upgrade`` / ``pipx upgrade`` - # operate on a named environment and ignore VIRTUAL_ENV, so we don't - # set it for them. - export_virtualenv = False - if is_uv_tool_install(): if not uv: print("✗ Detected a uv-tool install but managed uv install failed.") print(" Install uv manually: https://docs.astral.sh/uv/getting-started/installation/") sys.exit(1) cmd = [uv, "tool", "upgrade", "hermes-agent"] + print(f"→ Running: {' '.join(cmd)}") + result = subprocess.run(cmd) elif pipx_managed and pipx: # pipx owns its own venv; ``pipx upgrade`` is the only correct path. - # Matches scripts/auto-update.sh, which already uses pipx upgrade. cmd = [pipx, "upgrade", "hermes-agent"] - elif uv: - cmd = [uv, "pip", "install", "--upgrade", "hermes-agent"] - if in_venv: - # Launcher shim runs the venv interpreter but doesn't export - # VIRTUAL_ENV; without it uv errors "No virtual environment found". - export_virtualenv = True - else: - # Outside any venv, ``--system`` lets uv target the active - # interpreter, matching pip's default behaviour. - cmd.insert(3, "--system") + print(f"→ Running: {' '.join(cmd)}") + result = subprocess.run(cmd) else: - cmd = get_pip_cmd() + ["install", "--upgrade", "hermes-agent"] + # Not a uv-tool or pipx install. The supported path is the git + # checkout created by install.sh — it has a .git dir and uses + # ``hermes update`` (the git-pull + reinstall path) not pip. + # A raw ``pip install hermes-agent`` from PyPI is no longer supported. + print("✗ Cannot update: PyPI-based installs (pip install hermes-agent) are no longer supported.") + print() + print(" Please reinstall using the official installer, then use `hermes update`:") + print(" curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash") + sys.exit(1) - print(f"→ Running: {' '.join(cmd)}") - run_kwargs = {} - if export_virtualenv: - run_kwargs["env"] = {**os.environ, "VIRTUAL_ENV": sys.prefix} - result = subprocess.run(cmd, **run_kwargs) if result.returncode != 0: print("✗ Update failed") sys.exit(1) @@ -8053,6 +8051,7 @@ def _cmd_update_pip(args): print("✓ Update complete! Restart hermes to use the new version.") + def _cmd_update_impl(args, gateway_mode: bool): """Body of ``cmd_update`` — kept separate so the wrapper can always restore stdio even on ``sys.exit``.""" @@ -8459,7 +8458,7 @@ def _cmd_update_impl(args, gateway_mode: bool): # the install + core-dependency verification completes below. _write_update_incomplete_marker() print("→ Updating Python dependencies...") - from hermes_cli.managed_uv import ensure_uv, get_pip_cmd, update_managed_uv + from hermes_cli.managed_uv import ensure_uv, update_managed_uv # Keep managed uv current — runs `uv self update` if we already have one. update_managed_uv() @@ -8473,7 +8472,7 @@ def _cmd_update_impl(args, gateway_mode: bool): print(" → Termux detected: using uv + curated termux-all optional profile...") if _is_termux_env() and _is_android_python(): print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat(get_pip_cmd()) + _install_psutil_android_compat() _install_python_dependencies_with_optional_fallback(group=install_group) else: # Degenerate fallback: managed uv failed to install. @@ -8484,7 +8483,7 @@ def _cmd_update_impl(args, gateway_mode: bool): print(" → Termux detected: using curated termux-all optional profile...") if _is_termux_env() and _is_android_python(): print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat(get_pip_cmd()) + _install_psutil_android_compat() _install_python_dependencies_with_optional_fallback(group=install_group) else: # Ultimate degenerate fallback: no uv at all. @@ -8493,7 +8492,7 @@ def _cmd_update_impl(args, gateway_mode: bool): print(" → Termux detected: using curated termux-all optional profile...") if _is_termux_env() and _is_android_python(): print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...") - _install_psutil_android_compat(get_pip_cmd()) + _install_psutil_android_compat() _install_python_dependencies_with_optional_fallback(group=install_group) # Core Python deps installed AND verified (the fallback helper runs diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index d34e49ed8a..fc79a4e6fd 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -283,9 +283,9 @@ def get_pip_cmd() -> list[str]: def get_venv_root() -> Path: """Return the root path of the active virtual environment. - + Prefers `sys.prefix` (the standard Python way to identify a venv). - Falls back to the parent of the parent of `sys.executable` + Falls back to the parent of the parent of `sys.executable` (e.g., `/path/to/venv/bin/python` -> `/path/to/venv`). """ if sys.prefix != sys.base_prefix: @@ -293,6 +293,43 @@ def get_venv_root() -> Path: return Path(sys.executable).parent.parent +def get_venv_path() -> Path: + """Return the project's virtual environment directory. + + This is the single authoritative way to locate the Hermes venv on disk. + Previously scattered as ``PROJECT_ROOT / "venv"`` or ``PROJECT_ROOT / ".venv"`` + throughout the codebase. + + Resolution order: + 1. The active venv (``sys.prefix``) when hermes is running inside one. + This covers every production layout: + - dev checkout: /.venv or /venv + - normal user install: ~/.hermes/hermes-agent/venv + - root/system install: /usr/local/lib/hermes-agent/venv + - docker: /opt/hermes/.venv + - nix package install: /nix/store/-hermes-agent-python3.12-env + - nix devshell: /.venv + Hermes is always invoked through its own venv wrapper, so sys.prefix + reliably points to the right place. + 2. ``/.venv`` if it exists on disk (pre-activation fallback). + 3. ``/venv`` if it exists on disk. + 4. ``/venv`` as the canonical default (may not exist yet, + e.g. before the first install run). + """ + # we're unning inside a venv + if sys.prefix != sys.base_prefix: + return Path(sys.prefix).resolve() + + # we're not in a venv, probe conventional names under the source root + from hermes_constants import get_hermes_source_root + source_root = get_hermes_source_root() + for name in (".venv", "venv"): + candidate = source_root / name + if candidate.exists(): + return candidate + return source_root / "venv" + + def pip_install( packages: list[str], *, @@ -300,21 +337,24 @@ def pip_install( timeout: int = 300, capture_output: bool = True, quiet: bool = False, + upgrade: bool = False, ) -> subprocess.CompletedProcess: """Install packages using the managed uv binary (with degenerate pip fallback). - + This is the single, authoritative way to install Python dependencies in Hermes. It automatically: 1. Resolves the correct venv root. 2. Sets `VIRTUAL_ENV` and prepends the venv `bin` to `PATH`. - 3. Strips `PYTHONPATH` and `PYTHONHOME` to prevent venv contamination + 3. Strips `PYTHONPATH` and `PYTHONHOME` to prevent venv contamination (critical for Termux/Android compatibility). 4. Uses `get_pip_cmd()` to guarantee the managed uv binary is used. """ if venv_root is None: venv_root = get_venv_root() - + cmd = get_pip_cmd() + ["install"] + if upgrade: + cmd.append("--upgrade") if quiet: cmd.append("--quiet") cmd.extend(packages) diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index ba1ab297ed..7ad2c7d571 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -129,7 +129,8 @@ def _catalog_root() -> Path: """Return the optional-mcps/ directory shipped with this Hermes install.""" # Prefer the env-var override / packaged location; fall back to the repo's # optional-mcps/ next to the package (source checkout). - return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps") + from hermes_constants import get_hermes_source_root + return get_optional_mcps_dir(get_hermes_source_root() / "optional-mcps") def _parse_env_spec(raw: Any) -> EnvVarSpec: diff --git a/hermes_cli/memory_setup.py b/hermes_cli/memory_setup.py index 71f3ea2390..d7a75314e5 100644 --- a/hermes_cli/memory_setup.py +++ b/hermes_cli/memory_setup.py @@ -14,7 +14,7 @@ import subprocess import sys from pathlib import Path -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from hermes_constants import get_hermes_home from hermes_cli.secret_prompt import masked_secret_prompt @@ -99,20 +99,15 @@ def _install_dependencies(provider_name: str) -> None: print(f"\n Installing dependencies: {', '.join(missing)}") - # Use the centralized helper for consistent uv-first installation - install_cmd = get_pip_cmd() + ["install", "--quiet"] + missing - manual_cmd = f"{' '.join(get_pip_cmd())} install {' '.join(missing)}" - + manual_cmd = f"uv pip install {' '.join(missing)}" try: - subprocess.run( - install_cmd, - check=True, timeout=120, - capture_output=True, - ) + result = pip_install(missing, quiet=True, timeout=120) + if result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr) print(f" ✓ Installed {', '.join(missing)}") except subprocess.CalledProcessError as e: print(f" ⚠ Failed to install {', '.join(missing)}") - stderr = (e.stderr or b"").decode()[:200] + stderr = (e.stderr or b"").decode()[:200] if isinstance(e.stderr, bytes) else (e.stderr or "")[:200] if stderr: print(f" {stderr}") print(f" Run manually: {manual_cmd}") diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index d68f986583..b1cfc04561 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -151,10 +151,11 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: def _has_agent_browser() -> bool: import shutil + from hermes_constants import get_hermes_source_root agent_browser_bin = shutil.which("agent-browser") local_bin = ( - Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" + get_hermes_source_root() / "node_modules" / ".bin" / "agent-browser" ) return bool(agent_browser_bin or local_bin.exists()) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index d5cb7e8fe0..9b45dd1873 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -62,7 +62,8 @@ def get_bundled_plugins_dir() -> Path: env_override = os.getenv("HERMES_BUNDLED_PLUGINS") if env_override: return Path(env_override) - return Path(__file__).resolve().parent.parent / "plugins" + from hermes_constants import get_hermes_source_root + return get_hermes_source_root() / "plugins" try: import yaml diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index b800665f6a..db22a491d2 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -900,7 +900,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict "user_modified": [], "skipped_opt_out": True, } - project_root = Path(__file__).parent.parent.resolve() + from hermes_constants import get_hermes_source_root + project_root = get_hermes_source_root() try: result = subprocess.run( [sys.executable, "-c", diff --git a/hermes_cli/proxy/cli.py b/hermes_cli/proxy/cli.py index 7c7b86caf0..aac069d3e0 100644 --- a/hermes_cli/proxy/cli.py +++ b/hermes_cli/proxy/cli.py @@ -21,7 +21,7 @@ logger = logging.getLogger(__name__) def _print_aiohttp_missing() -> None: print( "hermes proxy requires aiohttp. Install one of:\n" - " pip install 'hermes-agent[messaging]'\n" + " uv pip install -e '.[messaging]' # from the hermes-agent checkout\n" " pip install aiohttp", file=sys.stderr, ) diff --git a/hermes_cli/proxy/server.py b/hermes_cli/proxy/server.py index 17e8615fcb..3b0a4a2ecd 100644 --- a/hermes_cli/proxy/server.py +++ b/hermes_cli/proxy/server.py @@ -86,7 +86,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application": if not AIOHTTP_AVAILABLE: raise RuntimeError( "aiohttp is required for `hermes proxy`. Install with: " - "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + "uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp." ) app = web.Application() @@ -253,7 +253,7 @@ async def run_server( if not AIOHTTP_AVAILABLE: raise RuntimeError( "aiohttp is required for `hermes proxy`. Install with: " - "pip install 'hermes-agent[messaging]' or `pip install aiohttp`." + "uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp." ) app = create_app(adapter) diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 99aee9d4dd..8e38effa2f 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -22,7 +22,7 @@ import copy from pathlib import Path from typing import Optional, Dict, Any -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from hermes_cli.nous_subscription import get_nous_subscription_features from tools.tool_backend_helpers import managed_nous_tools_enabled from utils import base_url_hostname @@ -30,7 +30,8 @@ from hermes_constants import get_optional_skills_dir logger = logging.getLogger(__name__) -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() _DOCS_BASE = "https://hermes-agent.nousresearch.com/docs" @@ -798,10 +799,8 @@ def _install_neutts_deps() -> bool: print_info("This will also download the TTS model (~300MB) on first use.") print() try: - subprocess.run( - get_pip_cmd() + ["install", "-U", "neutts[all]", "--quiet"], - check=True, timeout=300, - ) + result = pip_install(["neutts[all]"], quiet=True, upgrade=True, timeout=300) + result.check_returncode() print_success("neutts installed successfully") return True except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: @@ -823,10 +822,8 @@ def _install_kittentts_deps() -> bool: print_info("Installing kittentts Python package (~25-80MB model downloaded on first use)...") print() try: - subprocess.run( - get_pip_cmd() + ["install", "-U", wheel_url, "soundfile", "--quiet"], - check=True, timeout=300, - ) + result = pip_install([wheel_url, "soundfile"], quiet=True, upgrade=True, timeout=300) + result.check_returncode() print_success("kittentts installed successfully") return True except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e: @@ -1287,11 +1284,7 @@ def setup_terminal_backend(config: dict): text=True, ) else: - result = subprocess.run( - get_pip_cmd() + ["install", "modal"], - capture_output=True, - text=True, - ) + result = pip_install(["modal"]) if result.returncode == 0: print_success("modal SDK installed") else: @@ -1340,11 +1333,7 @@ def setup_terminal_backend(config: dict): text=True, ) else: - result = subprocess.run( - get_pip_cmd() + ["install", "daytona"], - capture_output=True, - text=True, - ) + result = pip_install(["daytona"]) if result.returncode == 0: print_success("daytona SDK installed") else: @@ -1990,10 +1979,7 @@ def _setup_matrix(): capture_output=True, text=True, ) else: - result = subprocess.run( - get_pip_cmd() + ["install", matrix_pkg], - capture_output=True, text=True, - ) + result = pip_install([matrix_pkg]) if result.returncode == 0: print_success(f"{matrix_pkg} installed") else: diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 71c50ba12b..3dede0da0b 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -9,7 +9,8 @@ import sys import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions from pathlib import Path -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() from hermes_cli.auth import AuthError, resolve_provider from hermes_cli.colors import Colors, color diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 19b1038fed..0de47acd14 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -35,7 +35,8 @@ from utils import base_url_hostname, is_truthy_value logger = logging.getLogger(__name__) -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() # ─── UI Helpers (shared with setup.py) ──────────────────────────────────────── @@ -771,6 +772,8 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) - def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil + import subprocess + if post_setup_key in {"agent_browser", "browserbase"}: node_modules = PROJECT_ROOT / "node_modules" / "agent-browser" npm_bin = shutil.which("npm") @@ -778,7 +781,6 @@ def _run_post_setup(post_setup_key: str): # Step 1: install the agent-browser npm package into node_modules/ if not node_modules.exists() and npm_bin: _print_info(" Installing Node.js dependencies for browser tools...") - import subprocess # Use the resolved npm_bin absolute path so subprocess.Popen can # execute npm.cmd on Windows (CreateProcessW otherwise rejects # batch shims). On POSIX npm_bin is the plain path — same @@ -846,7 +848,6 @@ def _run_post_setup(post_setup_key: str): return _print_info(" Installing Chromium (~170MB one-time download)...") - import subprocess # Prefer the bundled agent-browser install subcommand so the # version of Chromium matches the CLI. Fall back to npx shim on # setups where the local bin stub isn't present. @@ -889,7 +890,6 @@ def _run_post_setup(post_setup_key: str): _npm_bin = shutil.which("npm") if not camofox_dir.exists() and _npm_bin: _print_info(" Installing Camofox browser server...") - import subprocess # Absolute npm path so .cmd shim executes on Windows. result = subprocess.run( # --workspaces=false avoids resolving apps/desktop. See #38772. diff --git a/hermes_cli/uninstall.py b/hermes_cli/uninstall.py index 9b53500734..f488359e94 100644 --- a/hermes_cli/uninstall.py +++ b/hermes_cli/uninstall.py @@ -25,9 +25,7 @@ def log_success(msg: str): def log_warn(msg: str): print(f"{color('⚠', Colors.YELLOW)} {msg}") -def get_project_root() -> Path: - """Get the project installation directory.""" - return Path(__file__).parent.parent.resolve() + def find_shell_configs() -> list: @@ -572,7 +570,8 @@ def run_uninstall(args): - Full uninstall: removes code + ~/.hermes/ (configs, data, logs) - Keep data: removes code but keeps ~/.hermes/ for future reinstall """ - project_root = get_project_root() + from hermes_constants import get_hermes_source_root + project_root = get_hermes_source_root() hermes_home = get_hermes_home() # Detect named profiles when uninstalling from the default root — diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index b7db433515..162d098bf3 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -37,7 +37,8 @@ from typing import Any, Dict, List, Optional, Tuple import yaml -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) diff --git a/hermes_constants.py b/hermes_constants.py index b9d633ba8b..190ea116a4 100644 --- a/hermes_constants.py +++ b/hermes_constants.py @@ -11,6 +11,28 @@ from contextvars import ContextVar, Token from pathlib import Path +# --------------------------------------------------------------------------- +# Source-root helper — single source of truth for the repo / install root. +# --------------------------------------------------------------------------- +# ``hermes_constants.py`` lives at the repo root, so its parent *is* the root. +# Every other module that previously did ``Path(__file__).parent.parent`` to +# climb out of sub-packages (hermes_cli/, gateway/, tools/, …) should call +# this instead. Tests can monkeypatch it directly without touching __file__. + +def get_hermes_source_root() -> Path: + """Return the Hermes Agent source / installation root directory. + + This is the single authoritative way to locate the project root. + Previously this was scattered across ~20 files as + ``Path(__file__).parent.parent.resolve()`` relative to each sub-package. + + Returns the directory that contains ``pyproject.toml``, ``hermes_constants.py``, + ``hermes_cli/``, ``tools/``, etc. + """ + return Path(__file__).resolve().parent + + + _profile_fallback_warned: bool = False _UNSET = object() _HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar( diff --git a/plugins/google_meet/cli.py b/plugins/google_meet/cli.py index 5483bf006c..c4489a432a 100644 --- a/plugins/google_meet/cli.py +++ b/plugins/google_meet/cli.py @@ -18,7 +18,7 @@ import sys from pathlib import Path from typing import Optional -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from hermes_constants import get_hermes_home from plugins.google_meet import process_manager as pm @@ -251,16 +251,9 @@ def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: # 1) pip deps — always safe, venv-scoped. pip_pkgs = ["playwright", "websockets"] print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}") - try: - res = _sp.run( - get_pip_cmd() + ["install", "--upgrade", *pip_pkgs], - check=False, - ) - if res.returncode != 0: - print(" pip install failed") - return 1 - except Exception as e: - print(f" pip install failed: {e}") + res = pip_install(pip_pkgs, upgrade=True, capture_output=False) + if res.returncode != 0: + print(" pip install failed") return 1 # 2) Playwright browsers — pulls chromium (~300MB first run). diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index ec885d46ab..c266d4ace5 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -11,7 +11,7 @@ import subprocess import sys from pathlib import Path -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from hermes_constants import get_hermes_home from plugins.memory.honcho.client import _host_block, profile_host_key, resolve_active_host, resolve_config_path, HOST from hermes_cli.config import cfg_get @@ -413,12 +413,7 @@ def _ensure_sdk_installed() -> bool: return False print(" Installing honcho-ai...", flush=True) - result = subprocess.run( - get_pip_cmd() + ["install", "honcho-ai>=2.0.1"], - capture_output=True, - text=True, - stdin=subprocess.DEVNULL, - ) + result = pip_install(["honcho-ai>=2.0.1"]) if result.returncode == 0: print(" Installed.\n") return True diff --git a/plugins/platforms/discord/adapter.py b/plugins/platforms/discord/adapter.py index 46544cd1f4..a9b51195a9 100644 --- a/plugins/platforms/discord/adapter.py +++ b/plugins/platforms/discord/adapter.py @@ -6604,7 +6604,7 @@ def register(ctx) -> None: check_fn=check_discord_requirements, is_connected=_is_connected, required_env=["DISCORD_BOT_TOKEN"], - install_hint="pip install 'hermes-agent[messaging]'", + install_hint="uv pip install -e '.[messaging]' # from the hermes-agent checkout", # Interactive setup wizard — replaces the central # hermes_cli/setup.py::_setup_discord function. Same shape as Teams. setup_fn=interactive_setup, diff --git a/plugins/platforms/google_chat/adapter.py b/plugins/platforms/google_chat/adapter.py index 6f73848812..f9359b8493 100644 --- a/plugins/platforms/google_chat/adapter.py +++ b/plugins/platforms/google_chat/adapter.py @@ -3299,7 +3299,7 @@ def register(ctx) -> None: "GOOGLE_CHAT_SUBSCRIPTION_NAME", "GOOGLE_CHAT_SERVICE_ACCOUNT_JSON", ], - install_hint="pip install 'hermes-agent[google_chat]'", + install_hint="uv pip install -e '.[google_chat]' # from the hermes-agent checkout", setup_fn=interactive_setup, # Env-driven auto-configuration — the core env-populator hook calls # this during ``_apply_env_overrides`` and seeds diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 8fa80dc159..fa6d399e9a 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -68,7 +68,7 @@ import sys from pathlib import Path from typing import Any, List, Optional, Tuple -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from utils import atomic_replace # after the in-tree → plugin migration. See adapter.py for context. logger = logging.getLogger("gateway.platforms.google_chat_user_oauth") @@ -380,16 +380,15 @@ def install_deps() -> bool: print("Installing Google Chat OAuth dependencies...") try: - subprocess.check_call( - get_pip_cmd() + ["install", "--quiet"] + _REQUIRED_PACKAGES, - stdout=subprocess.DEVNULL, - ) + result = pip_install(_REQUIRED_PACKAGES, quiet=True) + if result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, result.args) print("Dependencies installed.") return True except subprocess.CalledProcessError as exc: print(f"ERROR: Failed to install dependencies: {exc}") print("Or install via the optional extra:") - print(" pip install 'hermes-agent[google_chat]'") + print(" uv pip install -e '.[google_chat]' # from the hermes-agent checkout") return False diff --git a/providers/__init__.py b/providers/__init__.py index a394e74b33..3dd4836cea 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -37,6 +37,7 @@ import sys from pathlib import Path from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401 +from hermes_constants import get_hermes_source_root logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ _discovered = False # Repo-root ``plugins/model-providers/`` — populated at discovery time. _BUNDLED_PLUGINS_DIR = ( - Path(__file__).resolve().parent.parent / "plugins" / "model-providers" + get_hermes_source_root() / "plugins" / "model-providers" ) diff --git a/pyproject.toml b/pyproject.toml index b2a486aefd..b306d68d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "tenacity==9.1.4", "pyyaml==6.0.3", "ruamel.yaml==0.18.17", - "requests==2.33.0", # CVE-2026-25645 + "requests==2.33.0", # CVE-2026-25645 "jinja2==3.1.6", # Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4. # pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's @@ -82,7 +82,7 @@ dependencies = [ # it out of the lazy-install path that exists only for the heavy matrix deps. "Markdown==3.10.2", # Skills Hub (GitHub App JWT auth — optional, only needed for bot identity) - "PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179 + "PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179 # urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass) # and GHSA-qccp-gfcp-xxvc (header leak across origins). "urllib3>=2.7.0,<3", @@ -117,7 +117,7 @@ dependencies = [ [project.optional-dependencies] # Native Anthropic provider — only needed when provider=anthropic (not via # OpenRouter or other aggregators). -anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452 +anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452 # Web search backends — each only loaded when the user picks it as their # search provider (configured via `hermes tools` or config.yaml). exa = ["exa-py==2.10.2"] @@ -131,11 +131,34 @@ edge-tts = ["edge-tts==7.2.7"] modal = ["modal==1.3.4"] daytona = ["daytona==0.155.0"] hindsight = ["hindsight-client==0.6.1"] -dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710 -messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 -cron = [] # croniter is now a core dependency; this extra kept for back-compat +dev = [ + "debugpy==1.8.20", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", + "pytest-timeout==2.4.0", + "mcp==1.26.0", + "starlette==1.0.1", + "ty==0.0.21", + "ruff==0.15.10", + "setuptools==82.0.1", +] # starlette: CVE-2026-48710 +messaging = [ + "python-telegram-bot[webhooks]==22.6", + "discord.py[voice]==2.7.1", + "aiohttp==3.13.4", + "brotlicffi==1.2.0.1", + "slack-bolt==1.27.0", + "slack-sdk==3.40.1", + "qrcode==7.4.2", +] # aiohttp: CVE-2026-34513/34518/34519/34520/34525 +cron = [] # croniter is now a core dependency; this extra kept for back-compat slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"] -matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"] +matrix = [ + "mautrix[encryption]==0.21.0", + "aiosqlite==0.22.1", + "asyncpg==0.31.0", + "aiohttp-socks==0.11.0", +] # WeCom callback-mode adapter — parses untrusted XML POST bodies from # WeCom-controlled callback endpoints, so we use defusedxml (drop-in # replacement for stdlib xml.etree.ElementTree) to block billion-laughs @@ -171,7 +194,7 @@ vision = [] # `request.url` can be bypassed. We pin a patched Starlette directly in every # extra that exposes a Starlette-backed server surface so pip/uv can't resolve # a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock. -mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 +mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 nemo-relay = ["nemo-relay==0.3"] homeassistant = ["aiohttp==3.13.4"] sms = ["aiohttp==3.13.4"] @@ -179,7 +202,7 @@ sms = ["aiohttp==3.13.4"] # The cua-driver binary itself is installed via `hermes tools` post-setup # (curl install script); this extra just pins the MCP client used to talk # to it, which is already provided by the `mcp` extra. -computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 +computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710 acp = ["agent-client-protocol==0.9.0"] # mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version. # The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious @@ -213,7 +236,11 @@ termux-all = [ "hermes-agent[sms]", "hermes-agent[web]", ] -dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"] +dingtalk = [ + "dingtalk-stream==0.24.3", + "alibabacloud-dingtalk==2.2.42", + "qrcode==7.4.2", +] feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"] google = [ # Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts, @@ -275,7 +302,22 @@ hermes-agent = "run_agent:main" hermes-acp = "acp_adapter.entry:main" [tool.setuptools] -py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"] +py-modules = [ + "run_agent", + "model_tools", + "toolsets", + "batch_runner", + "trajectory_compressor", + "toolset_distributions", + "cli", + "hermes_bootstrap", + "hermes_constants", + "hermes_state", + "hermes_time", + "hermes_logging", + "utils", + "mcp_serve", +] [tool.setuptools.data-files] # i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is @@ -301,7 +343,12 @@ locales = ["locales/*.yaml"] "optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"] [tool.setuptools.package-data] -hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"] +hermes_cli = [ + "web_dist/**/*", + "tui_dist/**/*", + "scripts/install.sh", + "scripts/install.ps1", +] gateway = ["assets/**/*"] plugins = [ "*/dashboard/manifest.json", @@ -319,13 +366,30 @@ plugins = [ ] [tool.setuptools.packages.find] -include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"] +include = [ + "agent", + "agent.*", + "tools", + "tools.*", + "hermes_cli", + "hermes_cli.*", + "gateway", + "gateway.*", + "tui_gateway", + "tui_gateway.*", + "cron", + "acp_adapter", + "plugins", + "plugins.*", + "providers", + "providers.*", +] [tool.pytest.ini_options] testpaths = ["tests"] markers = [ - "integration: marks tests requiring external services (API keys, Modal, etc.)", - "real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances", + "integration: marks tests requiring external services (API keys, Modal, etc.)", + "real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances", ] # pytest-timeout: per-test 30s hard cap with cross-platform thread method. # This is the fallback inside each per-file pytest subprocess (see @@ -342,7 +406,7 @@ unknown-argument = "warn" redundant-cast = "ignore" [tool.ruff] -preview = true # required for PLW1514 (unspecified-encoding) — preview rule +preview = true # required for PLW1514 (unspecified-encoding) — preview rule [tool.ruff.lint] # All other lints are intentionally disabled (see comment history on this diff --git a/scripts/check-windows-footguns.py b/scripts/check-windows-footguns.py index 7ae7ca50c4..06b0b94d2b 100644 --- a/scripts/check-windows-footguns.py +++ b/scripts/check-windows-footguns.py @@ -37,7 +37,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Iterable -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE) diff --git a/scripts/check_subprocess_stdin.py b/scripts/check_subprocess_stdin.py index 4d312a5949..b3806582b2 100644 --- a/scripts/check_subprocess_stdin.py +++ b/scripts/check_subprocess_stdin.py @@ -135,7 +135,8 @@ def find_subprocess_calls(content: str, filepath: str) -> list[dict]: def main() -> int: fix_mode = "--fix" in sys.argv - repo_root = Path(__file__).resolve().parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() os.chdir(repo_root) all_violations = [] diff --git a/scripts/profile-tui.py b/scripts/profile-tui.py index 788fd464bc..8d726de230 100755 --- a/scripts/profile-tui.py +++ b/scripts/profile-tui.py @@ -35,7 +35,8 @@ import time from pathlib import Path from typing import Any -_PROJECT_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +_PROJECT_ROOT = get_hermes_source_root() sys.path.insert(0, str(_PROJECT_ROOT)) try: from hermes_constants import get_hermes_home @@ -46,7 +47,7 @@ except ImportError: DEFAULT_TUI_DIR = Path( os.environ.get("HERMES_TUI_DIR") - or str(Path(__file__).resolve().parent.parent / "ui-tui") + or str(_PROJECT_ROOT / "ui-tui") ) DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log"))) DEFAULT_STATE_DB = get_hermes_home() / "state.db" diff --git a/scripts/release.py b/scripts/release.py index bd40b25117..7a208d1056 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -30,7 +30,8 @@ from collections import defaultdict from datetime import datetime from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py" PYPROJECT_FILE = REPO_ROOT / "pyproject.toml" diff --git a/scripts/run_tests_parallel.py b/scripts/run_tests_parallel.py index f1b437d786..73108be309 100755 --- a/scripts/run_tests_parallel.py +++ b/scripts/run_tests_parallel.py @@ -739,7 +739,8 @@ def main() -> int: print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr) sys.exit(2) - repo_root = Path(__file__).resolve().parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() # Resolve discovery roots: positional path args override --paths if any # were supplied, otherwise --paths (which itself defaults to 'tests'). diff --git a/scripts/sample_and_compress.py b/scripts/sample_and_compress.py index a6358f45b5..0a6b778a32 100644 --- a/scripts/sample_and_compress.py +++ b/scripts/sample_and_compress.py @@ -360,7 +360,8 @@ def main( print(f" Seed: {seed}") # Setup paths - base_dir = Path(__file__).parent.parent + from hermes_constants import get_hermes_source_root + base_dir = get_hermes_source_root() sampled_dir = base_dir / "data" / f"{output_name}_raw" compressed_dir = base_dir / "data" / f"{output_name}_batches" final_output = base_dir / "data" / f"{output_name}.jsonl" diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 61d8c4bd81..0961b30d50 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -37,7 +37,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] if str(REPO_ROOT) not in sys.path: sys.path.insert(0, str(REPO_ROOT)) -from hermes_cli.managed_uv import get_pip_cmd +from hermes_cli.managed_uv import pip_install from pathlib import Path # Ensure sibling modules (_hermes_home) are importable when run standalone. @@ -116,10 +116,9 @@ def install_deps(): # First choice: pip in the current interpreter. Works for most installs. try: - subprocess.check_call( - get_pip_cmd() + ["install", "--quiet"] + REQUIRED_PACKAGES, - stdout=subprocess.DEVNULL, - ) + result = pip_install(REQUIRED_PACKAGES, quiet=True) + if result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, result.args) print("Dependencies installed.") return True except subprocess.CalledProcessError as e: @@ -150,7 +149,7 @@ def install_deps(): "On environments without pip (e.g. Nix, or the Hermes Docker image's " "uv-managed venv), install the optional extra instead:" ) - print(" pip install 'hermes-agent[google]'") + print(" uv pip install -e '.[google]' # from the hermes-agent checkout") print(f"Or manually: uv pip install {' '.join(REQUIRED_PACKAGES)}") return False diff --git a/tests/agent/test_auxiliary_config_bridge.py b/tests/agent/test_auxiliary_config_bridge.py index b2727d3360..7100265a36 100644 --- a/tests/agent/test_auxiliary_config_bridge.py +++ b/tests/agent/test_auxiliary_config_bridge.py @@ -203,7 +203,8 @@ class TestGatewayBridgeCodeParity: in source. Assert the dynamic shape and the canonical built-in keys bridged set instead. """ - gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py" + from hermes_constants import get_hermes_source_root + gateway_path = get_hermes_source_root() / "gateway" / "run.py" # Pin encoding to UTF-8: source files in this repo are UTF-8, but # Path.read_text() defaults to the system locale — which is cp1252 # on most Western Windows installs and crashes as soon as the file @@ -224,7 +225,8 @@ class TestGatewayBridgeCodeParity: def test_gateway_no_compression_env_bridge(self): """Gateway should NOT bridge compression config to env vars (config-only).""" - gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py" + from hermes_constants import get_hermes_source_root + gateway_path = get_hermes_source_root() / "gateway" / "run.py" # See note in test_gateway_has_auxiliary_bridge — pin UTF-8 so the # test runs on Windows where the default locale is cp1252. content = gateway_path.read_text(encoding="utf-8") diff --git a/tests/agent/test_bedrock_integration.py b/tests/agent/test_bedrock_integration.py index 65df149e52..1038b9775b 100644 --- a/tests/agent/test_bedrock_integration.py +++ b/tests/agent/test_bedrock_integration.py @@ -258,7 +258,8 @@ class TestPackaging: import tomllib from pathlib import Path - content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text() + from hermes_constants import get_hermes_source_root + content = (get_hermes_source_root() / "pyproject.toml").read_text() return tomllib.loads(content)["project"]["optional-dependencies"] def test_bedrock_extra_exists(self): diff --git a/tests/gateway/test_plugin_platform_interface.py b/tests/gateway/test_plugin_platform_interface.py index c2392cf827..1291f37261 100644 --- a/tests/gateway/test_plugin_platform_interface.py +++ b/tests/gateway/test_plugin_platform_interface.py @@ -14,7 +14,8 @@ from unittest.mock import MagicMock import pytest -PROJECT_ROOT = Path(__file__).parent.parent.resolve() +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() PLATFORMS_DIR = PROJECT_ROOT / "plugins" / "platforms" diff --git a/tests/gateway/test_session_state_cleanup.py b/tests/gateway/test_session_state_cleanup.py index dfde65eb38..39e300c12c 100644 --- a/tests/gateway/test_session_state_cleanup.py +++ b/tests/gateway/test_session_state_cleanup.py @@ -123,7 +123,8 @@ class TestNoMoreBareDeleteSites: from pathlib import Path import re - gateway_run = (Path(__file__).parent.parent.parent / "gateway" / "run.py").read_text() + from hermes_constants import get_hermes_source_root + gateway_run = (get_hermes_source_root() / "gateway" / "run.py").read_text() # Match `del self._running_agents[...]` that is NOT inside a # triple-quoted docstring. We scan non-docstring lines only. lines = gateway_run.splitlines() diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 224c65e9a4..c907207965 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -71,42 +71,41 @@ def _patch_managed_uv(request): class TestCmdUpdatePip: - """Regression tests for pip-install update flows.""" + """Tests for pip-install update flows.""" @patch("shutil.which", return_value="/usr/bin/uv") @patch("subprocess.run") - def test_update_pip_exports_virtualenv_from_sys_prefix( + def test_update_pip_uv_tool_upgrade( self, mock_run, _mock_which, mock_args, monkeypatch ): + """uv-tool installs still use uv tool upgrade.""" from hermes_cli import main as hm mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - hm._cmd_update_pip(mock_args) + with patch("hermes_cli.config.is_uv_tool_install", return_value=True): + hm._cmd_update_pip(mock_args) assert mock_run.call_count == 1 - assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv" + assert mock_run.call_args.args[0] == ["/usr/bin/uv", "tool", "upgrade", "hermes-agent"] @patch("shutil.which", return_value="/usr/bin/uv") - @patch("subprocess.run") - def test_update_pip_does_not_export_virtualenv_for_system_python( - self, mock_run, _mock_which, mock_args, monkeypatch + def test_update_pip_plain_pypi_install_exits_with_error( + self, _mock_which, mock_args, monkeypatch, capsys ): + """Plain pip install (not uv-tool, not pipx) now exits with a helpful error.""" from hermes_cli import main as hm - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) - monkeypatch.setattr(hm.sys, "prefix", "/usr") + monkeypatch.setattr(hm.sys, "prefix", "/tmp/some-venv") monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - hm._cmd_update_pip(mock_args) + with patch("hermes_cli.config.is_uv_tool_install", return_value=False), \ + pytest.raises(SystemExit) as exc_info: + hm._cmd_update_pip(mock_args) - assert mock_run.call_count == 1 - assert "env" not in mock_run.call_args.kwargs + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "no longer supported" in out + assert "hermes-agent.nousresearch.com/install.sh" in out class TestCmdUpdateBranchFallback: diff --git a/tests/hermes_cli/test_curses_color_compat.py b/tests/hermes_cli/test_curses_color_compat.py index 5b9ed954ea..7c3ee5ab9c 100644 --- a/tests/hermes_cli/test_curses_color_compat.py +++ b/tests/hermes_cli/test_curses_color_compat.py @@ -24,7 +24,8 @@ from unittest.mock import patch, MagicMock # Path to the source files under test -_SRC_ROOT = Path(__file__).parent.parent.parent / "hermes_cli" +from hermes_constants import get_hermes_source_root +_SRC_ROOT = get_hermes_source_root() / "hermes_cli" class TestInitPairClampingBehavior: diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index c9df041d06..1a96415e72 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -227,16 +227,19 @@ def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path, monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, "")) - issues = [] - doctor._check_gateway_service_linger(issues) + from hermes_cli.doctor._registry import DiagnosticReport + from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger + + report = DiagnosticReport() + report.section("Gateway Service") + check_gateway_service_linger(report) out = capsys.readouterr().out assert "Gateway Service" in out assert "Systemd linger disabled" in out assert "loginctl enable-linger" in out - assert issues == [ - "Enable linger for the gateway user service: sudo loginctl enable-linger $USER" - ] + issues = [i.text for i in report._issues] + assert any("linger" in i.lower() for i in issues) def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys): @@ -245,12 +248,16 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat monkeypatch.setattr(gateway_cli, "is_linux", lambda: True) monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path) - issues = [] - doctor._check_gateway_service_linger(issues) + from hermes_cli.doctor._registry import DiagnosticReport + from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger + + report = DiagnosticReport() + report.section("Gateway Service") + check_gateway_service_linger(report) out = capsys.readouterr().out assert out == "" - assert issues == [] + assert report._issues == [] # ── Memory provider section (doctor should only check the *active* provider) ── @@ -484,7 +491,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon out = buf.getvalue() assert "model.provider 'openrouter' is set but no API key is configured" in out - assert "No credentials found for provider 'openrouter'." in out @pytest.mark.parametrize( diff --git a/tests/hermes_cli/test_gui_uninstall.py b/tests/hermes_cli/test_gui_uninstall.py index 951f3ae8b9..b1aad1a426 100644 --- a/tests/hermes_cli/test_gui_uninstall.py +++ b/tests/hermes_cli/test_gui_uninstall.py @@ -240,7 +240,7 @@ def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch): # Stub every destructive external so the test only exercises the control # flow + the real GUI sweep (which is safe inside tmp_path). monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) - monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code) + monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code) monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False) monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: []) monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: []) @@ -274,7 +274,7 @@ def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch): fake_code.mkdir() monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home) - monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code) + monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code) monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False) monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: []) monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: []) diff --git a/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py index 8bb75fe629..b6962b5ecb 100644 --- a/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py +++ b/tests/hermes_cli/test_kanban_cli_dispatch_passthrough.py @@ -125,10 +125,8 @@ def test_kanban_swarm_uses_existing_humanizer_skill(): which actually exists at skills/creative/humanizer/SKILL.md.""" import pathlib - swarm_path = ( - pathlib.Path(__file__).resolve().parent.parent.parent - / "hermes_cli" / "kanban_swarm.py" - ) + from hermes_constants import get_hermes_source_root + swarm_path = get_hermes_source_root() / "hermes_cli" / "kanban_swarm.py" src = swarm_path.read_text() assert "avoid-ai-writing" not in src, ( "kanban_swarm.py must not reference 'avoid-ai-writing' — that " @@ -140,9 +138,7 @@ def test_kanban_swarm_uses_existing_humanizer_skill(): ) # And the replacement skill must actually exist on disk. - skills_root = ( - pathlib.Path(__file__).resolve().parent.parent.parent / "skills" - ) + skills_root = get_hermes_source_root() / "skills" humanizer_path = skills_root / "creative" / "humanizer" / "SKILL.md" assert humanizer_path.is_file(), ( f"humanizer skill missing at {humanizer_path}; the kanban_swarm fix " diff --git a/tests/hermes_cli/test_pip_install_detection.py b/tests/hermes_cli/test_pip_install_detection.py index eb06e35f2b..0d096fd78c 100644 --- a/tests/hermes_cli/test_pip_install_detection.py +++ b/tests/hermes_cli/test_pip_install_detection.py @@ -31,12 +31,10 @@ def test_managed_install_takes_precedence(tmp_path): def test_recommended_update_command_pip(): - """Pip installs recommend pip install --upgrade.""" + """Pip installs now redirect to the official installer.""" from hermes_cli.config import recommended_update_command_for_method cmd = recommended_update_command_for_method("pip") - assert "pip install" in cmd or "uv pip install" in cmd - assert "--upgrade" in cmd - assert "hermes-agent" in cmd + assert "hermes-agent.nousresearch.com/install.sh" in cmd def test_stamp_file_takes_precedence(tmp_path): @@ -103,8 +101,8 @@ def test_banner_warns_on_pip_install(tmp_path): ) out = buf.getvalue() - assert "officially" in out - assert "instability" in out + assert "no longer supported" in out + assert "install.sh" in out def test_banner_no_pip_warning_on_git_install(tmp_path): diff --git a/tests/hermes_cli/test_signal_handler_kanban_worker.py b/tests/hermes_cli/test_signal_handler_kanban_worker.py index 445e80e2f5..cfdfc3fc39 100644 --- a/tests/hermes_cli/test_signal_handler_kanban_worker.py +++ b/tests/hermes_cli/test_signal_handler_kanban_worker.py @@ -210,9 +210,8 @@ def test_real_handler_uses_os_exit_for_kanban_workers(): """ import pathlib - cli_path = ( - pathlib.Path(__file__).resolve().parent.parent.parent / "cli.py" - ) + from hermes_constants import get_hermes_source_root + cli_path = get_hermes_source_root() / "cli.py" src = cli_path.read_text() # Locate the handler body. start = src.find("def _signal_handler_q(signum, frame):") diff --git a/tests/hermes_cli/test_uv_tool_update.py b/tests/hermes_cli/test_uv_tool_update.py index b8f294de37..fefef8754b 100644 --- a/tests/hermes_cli/test_uv_tool_update.py +++ b/tests/hermes_cli/test_uv_tool_update.py @@ -140,28 +140,25 @@ class TestRecommendedUpdateCommandForUvTool: cmd = config.recommended_update_command_for_method("pip") assert cmd == "uv tool upgrade hermes-agent" - def test_uv_pip_install_keeps_legacy_recommendation(self): - """Existing behavior: uv is on PATH but Hermes is a regular pip install.""" - from hermes_cli import config - - with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch.object(config, "is_uv_tool_install", return_value=False): - cmd = config.recommended_update_command_for_method("pip") - assert cmd == "uv pip install --upgrade hermes-agent" - - def test_no_uv_falls_back_to_plain_pip(self): + def test_uv_pip_install_redirects_to_installer(self): + """Non-uv-tool, non-pipx pip install → redirect to official installer.""" from hermes_cli import config with patch("shutil.which", return_value=None), \ patch.object(config, "is_uv_tool_install", return_value=False): cmd = config.recommended_update_command_for_method("pip") - assert cmd == "pip install --upgrade hermes-agent" + assert "hermes-agent.nousresearch.com/install.sh" in cmd + + def test_no_uv_also_redirects_to_installer(self): + from hermes_cli import config + + with patch("shutil.which", return_value=None), \ + patch.object(config, "is_uv_tool_install", return_value=False): + cmd = config.recommended_update_command_for_method("pip") + assert "hermes-agent.nousresearch.com/install.sh" in cmd def test_recommendation_does_not_spawn_subprocess(self): - """Computing the recommendation string must be cheap — no ``uv tool list`` - spawn. Copilot review on PR #29703 flagged the prior subprocess hop - as adding overhead and a multi-second timeout window for what is - purely a display string.""" + """Computing the recommendation string must be cheap — no subprocess spawn.""" from hermes_cli import config with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \ @@ -170,7 +167,7 @@ class TestRecommendedUpdateCommandForUvTool: patch("subprocess.run") as mock_run: cmd = config.recommended_update_command_for_method("pip") mock_run.assert_not_called() - assert cmd == "uv pip install --upgrade hermes-agent" + assert "hermes-agent.nousresearch.com/install.sh" in cmd # --------------------------------------------------------------------------- @@ -191,35 +188,29 @@ class TestCmdUpdatePipUsesUvTool: assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"] - @patch("subprocess.run") - def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run): - """Existing behavior preserved when uv is present but Hermes isn't a tool install.""" + def test_runs_uv_pip_install_when_not_uv_tool(self, capsys): + """Non-uv-tool with uv present → PyPI path, now exits with error.""" from hermes_cli.main import _cmd_update_pip - mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="") with patch("shutil.which", return_value="/usr/local/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): + patch("hermes_cli.config.is_uv_tool_install", return_value=False), \ + pytest.raises(SystemExit) as exc_info: _cmd_update_pip(SimpleNamespace()) - assert mock_run.call_args[0][0] == [ - "/usr/local/bin/uv", - "pip", - "install", - "--upgrade", - "hermes-agent", - ] + assert exc_info.value.code == 1 + assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out - @patch("subprocess.run") - def test_falls_back_to_pip_when_no_uv(self, mock_run): + def test_falls_back_to_pip_when_no_uv(self, capsys): + """No uv, not uv-tool → PyPI path, now exits with error.""" from hermes_cli.main import _cmd_update_pip - mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="") with patch("shutil.which", return_value=None), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): + patch("hermes_cli.config.is_uv_tool_install", return_value=False), \ + pytest.raises(SystemExit) as exc_info: _cmd_update_pip(SimpleNamespace()) - cmd = mock_run.call_args[0][0] - assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"] + assert exc_info.value.code == 1 + assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out @patch("subprocess.run") def test_exits_nonzero_on_subprocess_failure(self, mock_run): @@ -255,12 +246,7 @@ class TestCmdUpdatePipUsesUvTool: class TestCmdUpdatePipInstallLayouts: - """The uv pip path must adapt to where the running interpreter lives: - - - inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system`` - - bare pip outside any venv -> add ``--system``, no overlay - - pipx-managed -> ``pipx upgrade`` - """ + """Update path tests for various install layouts.""" @patch("subprocess.run") def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch): @@ -278,65 +264,39 @@ class TestCmdUpdatePipInstallLayouts: hm._cmd_update_pip(SimpleNamespace()) assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"] - # pipx upgrade ignores VIRTUAL_ENV; we must not set it. assert "env" not in mock_run.call_args.kwargs - @patch("subprocess.run") - def test_pipx_layout_without_pipx_binary_treated_as_venv( - self, mock_run, monkeypatch + def test_pipx_layout_without_pipx_binary_exits_with_error( + self, monkeypatch, capsys ): + """pipx layout detected but no pipx binary → unsupported, show installer.""" from hermes_cli import main as hm - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent") monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - # pipx layout detected via prefix, but pipx binary missing on PATH. - def _which(name): - return "/usr/bin/uv" if name == "uv" else None - - with patch("shutil.which", side_effect=_which), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): + with patch("shutil.which", return_value=None), \ + patch("hermes_cli.config.is_uv_tool_install", return_value=False), \ + pytest.raises(SystemExit) as exc_info: hm._cmd_update_pip(SimpleNamespace()) - # prefix != base_prefix, so this is treated as a venv -> overlay, no --system. - assert mock_run.call_args[0][0] == [ - "/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent", - ] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent") + assert exc_info.value.code == 1 + assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out - @patch("subprocess.run") - def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch): + def test_plain_venv_pip_install_exits_with_error(self, monkeypatch, capsys): + """Non-uv-tool, non-pipx regular venv → PyPI path, now unsupported.""" from hermes_cli import main as hm - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - # No venv: prefix == base_prefix. - monkeypatch.setattr(hm.sys, "prefix", "/usr") - monkeypatch.setattr(hm.sys, "base_prefix", "/usr") - - with patch("shutil.which", return_value="/usr/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): - hm._cmd_update_pip(SimpleNamespace()) - - assert mock_run.call_args[0][0] == [ - "/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent", - ] - assert "env" not in mock_run.call_args.kwargs - - @patch("subprocess.run") - def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch): - from hermes_cli import main as hm - - mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="") - monkeypatch.delenv("VIRTUAL_ENV", raising=False) monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv") monkeypatch.setattr(hm.sys, "base_prefix", "/usr") with patch("shutil.which", return_value="/usr/bin/uv"), \ - patch("hermes_cli.config.is_uv_tool_install", return_value=False): + patch("hermes_cli.config.is_uv_tool_install", return_value=False), \ + pytest.raises(SystemExit) as exc_info: hm._cmd_update_pip(SimpleNamespace()) - cmd = mock_run.call_args[0][0] - assert "--system" not in cmd - assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"] - assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv" + assert exc_info.value.code == 1 + out = capsys.readouterr().out + assert "no longer supported" in out + assert "hermes-agent.nousresearch.com/install.sh" in out + diff --git a/tests/run_agent/test_callable_api_key.py b/tests/run_agent/test_callable_api_key.py index ce5bb19d6b..5116c1521f 100644 --- a/tests/run_agent/test_callable_api_key.py +++ b/tests/run_agent/test_callable_api_key.py @@ -31,6 +31,8 @@ from unittest.mock import MagicMock import pytest +from hermes_constants import get_hermes_source_root as _repo_root + # --------------------------------------------------------------------------- # OpenAI SDK construction preserves the callable @@ -245,8 +247,7 @@ class TestBatchRunnerCallableHandling: """Pin the predicate string in batch_runner so refactors that change it are caught here. Reading the source rather than importing avoids spinning up the full BatchRunner.""" - from pathlib import Path - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "batch_runner.py").read_text() assert "callable(self.api_key) and not isinstance(self.api_key, str)" in src, ( "BatchRunner.api_key callable check changed — update test or " @@ -278,7 +279,7 @@ class TestCliEnsureRuntimeCredentialsCallable: # ``_ensure_runtime_credentials`` was extracted from cli.py into the # ``CLIAgentSetupMixin`` (god-file decomposition Phase 4). Read the # module the method actually lives in now. - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "hermes_cli" / "cli_agent_setup_mixin.py").read_text() # The fix introduces ``_is_callable_provider`` which gates the # string-only check so callable token providers survive. @@ -305,8 +306,7 @@ class TestInlinedDisplayMasks: client init paths must guard their banner prints with ``is_token_provider`` so a callable Entra ID provider doesn't crash ``len(api_key)``.""" - from pathlib import Path - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "agent" / "agent_init.py").read_text() assert src.count("is_token_provider(") >= 2, ( "agent/agent_init.py must guard BOTH masked-banner paths " @@ -325,8 +325,7 @@ class TestInlinedDisplayMasks: callable Entra ID providers. The inlined version uses ``is_token_provider`` and prints the same static label as the run_agent banners.""" - from pathlib import Path - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "cli.py").read_text() assert "is_token_provider(self.api_key)" in src, ( "cli.HermesCLI.show_config must guard self.api_key via " @@ -346,8 +345,7 @@ class TestInlinedDisplayMasks: defensively the helper must also accept a callable directly and return the placeholder rather than crashing on ``len(callable)``.""" - from pathlib import Path - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "run_agent.py").read_text() # The function now starts with a callable check. assert ( @@ -365,8 +363,7 @@ class TestInlinedDisplayMasks: was extracted after this feature was first written). It used to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID + Anthropic-style mode that's a callable; slicing crashes.""" - from pathlib import Path - src = (Path(__file__).resolve().parent.parent.parent + src = (_repo_root() / "agent" / "conversation_loop.py").read_text() # The Anthropic 401 block now branches on is_token_provider # before slicing the key. diff --git a/tests/test_atomic_replace_symlinks.py b/tests/test_atomic_replace_symlinks.py index f6b8491832..8e5fa58d99 100644 --- a/tests/test_atomic_replace_symlinks.py +++ b/tests/test_atomic_replace_symlinks.py @@ -21,7 +21,8 @@ import pytest import yaml # Ensure the repo root is importable when running via `pytest tests/...`. -_REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +_REPO_ROOT = get_hermes_source_root() if str(_REPO_ROOT) not in sys.path: sys.path.insert(0, str(_REPO_ROOT)) diff --git a/tests/test_dashboard_sidecar_close_on_disconnect.py b/tests/test_dashboard_sidecar_close_on_disconnect.py index bb11e688cf..2dcf84f7e8 100644 --- a/tests/test_dashboard_sidecar_close_on_disconnect.py +++ b/tests/test_dashboard_sidecar_close_on_disconnect.py @@ -1,7 +1,8 @@ import re from pathlib import Path -CHAT_SIDEBAR = Path(__file__).resolve().parent.parent / "web/src/components/ChatSidebar.tsx" +from hermes_constants import get_hermes_source_root +CHAT_SIDEBAR = get_hermes_source_root() / "web/src/components/ChatSidebar.tsx" def test_sidecar_session_create_requests_close_on_disconnect(): diff --git a/tests/test_desktop_mac_entitlements.py b/tests/test_desktop_mac_entitlements.py index 5877d5b257..a6740384a0 100644 --- a/tests/test_desktop_mac_entitlements.py +++ b/tests/test_desktop_mac_entitlements.py @@ -26,7 +26,8 @@ from pathlib import Path import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron" MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist" INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist" diff --git a/tests/test_docker_home_override_scripts.py b/tests/test_docker_home_override_scripts.py index b575978539..badd9a5ae4 100644 --- a/tests/test_docker_home_override_scripts.py +++ b/tests/test_docker_home_override_scripts.py @@ -3,7 +3,8 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run" MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh" STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh" diff --git a/tests/test_dockerfile_tini_compat_shim.py b/tests/test_dockerfile_tini_compat_shim.py index e396c8625c..942c049429 100644 --- a/tests/test_dockerfile_tini_compat_shim.py +++ b/tests/test_dockerfile_tini_compat_shim.py @@ -12,7 +12,8 @@ from pathlib import Path def _dockerfile_text() -> str: - return (Path(__file__).parent.parent / "Dockerfile").read_text(encoding="utf-8") + from hermes_constants import get_hermes_source_root + return (get_hermes_source_root() / "Dockerfile").read_text(encoding="utf-8") def test_tini_compat_symlink_present(): diff --git a/tests/test_evidence_store.py b/tests/test_evidence_store.py index 0bdc16ed16..afb5aa615f 100644 --- a/tests/test_evidence_store.py +++ b/tests/test_evidence_store.py @@ -3,7 +3,8 @@ from pathlib import Path import importlib.util # Load the hyphenated script name dynamically -repo_root = Path(__file__).parent.parent +from hermes_constants import get_hermes_source_root +repo_root = get_hermes_source_root() script_path = repo_root / "optional-skills" / "security" / "oss-forensics" / "scripts" / "evidence-store.py" spec = importlib.util.spec_from_file_location("evidence_store", str(script_path)) diff --git a/tests/test_install_no_initial_commit.py b/tests/test_install_no_initial_commit.py index 321ddd0b40..0026043b41 100644 --- a/tests/test_install_no_initial_commit.py +++ b/tests/test_install_no_initial_commit.py @@ -21,7 +21,8 @@ from pathlib import Path import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" diff --git a/tests/test_install_sh_browser_install.py b/tests/test_install_sh_browser_install.py index 6ec3b56538..b6dbe963a4 100644 --- a/tests/test_install_sh_browser_install.py +++ b/tests/test_install_sh_browser_install.py @@ -8,7 +8,8 @@ unsupported distribution. from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_install_sh_pythonpath_sanitization.py b/tests/test_install_sh_pythonpath_sanitization.py index 0fd4c14d92..b0e750269f 100644 --- a/tests/test_install_sh_pythonpath_sanitization.py +++ b/tests/test_install_sh_pythonpath_sanitization.py @@ -8,7 +8,8 @@ must sanitize those vars both during installation and at runtime launch. from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_install_sh_root_fhs_uv_python_path.py b/tests/test_install_sh_root_fhs_uv_python_path.py index 0f1c5fa725..108335b3db 100644 --- a/tests/test_install_sh_root_fhs_uv_python_path.py +++ b/tests/test_install_sh_root_fhs_uv_python_path.py @@ -10,7 +10,8 @@ and the shared ``/usr/local/bin/hermes`` wrapper fails for non-root users with from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_install_sh_setup_wizard_tty_probe.py b/tests/test_install_sh_setup_wizard_tty_probe.py index a9f8a26e75..59accdcd4f 100644 --- a/tests/test_install_sh_setup_wizard_tty_probe.py +++ b/tests/test_install_sh_setup_wizard_tty_probe.py @@ -20,7 +20,8 @@ from pathlib import Path import pytest -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" # Every function in scripts/install.sh that previously gated on a bare diff --git a/tests/test_install_sh_symlink_stomp.py b/tests/test_install_sh_symlink_stomp.py index 0fbe508509..818a624260 100644 --- a/tests/test_install_sh_symlink_stomp.py +++ b/tests/test_install_sh_symlink_stomp.py @@ -22,7 +22,8 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_install_sh_termux_network_prereqs.py b/tests/test_install_sh_termux_network_prereqs.py index 891cf54d13..aaa1b67ef4 100644 --- a/tests/test_install_sh_termux_network_prereqs.py +++ b/tests/test_install_sh_termux_network_prereqs.py @@ -3,7 +3,8 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_lint_config.py b/tests/test_lint_config.py index 5d8eda2ae4..33b7745580 100644 --- a/tests/test_lint_config.py +++ b/tests/test_lint_config.py @@ -27,7 +27,8 @@ try: except ImportError: # pragma: no cover — 3.10 and earlier import tomli as tomllib # type: ignore -REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() def _load_pyproject() -> dict: diff --git a/tests/test_package_json_lazy_deps.py b/tests/test_package_json_lazy_deps.py index 0e2456dba2..d127c7fcfc 100644 --- a/tests/test_package_json_lazy_deps.py +++ b/tests/test_package_json_lazy_deps.py @@ -34,7 +34,8 @@ import json from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() def _root_package_json() -> dict: diff --git a/tests/test_run_tests_parallel.py b/tests/test_run_tests_parallel.py index d21e5e01eb..132edc9cd9 100644 --- a/tests/test_run_tests_parallel.py +++ b/tests/test_run_tests_parallel.py @@ -74,7 +74,8 @@ def test_grandchild_leak_is_killed_by_runner(tmp_path: Path) -> None: 3. Wait for the grandchild PID to vanish (poll for ~5s). 4. Assert the runner exited cleanly AND the grandchild is dead. """ - repo_root = Path(__file__).resolve().parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() runner = repo_root / "scripts" / "run_tests_parallel.py" assert runner.exists(), f"runner missing at {runner}" @@ -196,7 +197,8 @@ import importlib.util as _importlib_util # noqa: E402 def _load_runner_module(): """Import scripts/run_tests_parallel.py as a module for in-process tests.""" - repo_root = Path(__file__).resolve().parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() path = repo_root / "scripts" / "run_tests_parallel.py" spec = _importlib_util.spec_from_file_location("_rtp_under_test", path) mod = _importlib_util.module_from_spec(spec) diff --git a/tests/test_termux_all_extra_compat.py b/tests/test_termux_all_extra_compat.py index 0a1ee11aae..4965bc7e83 100644 --- a/tests/test_termux_all_extra_compat.py +++ b/tests/test_termux_all_extra_compat.py @@ -3,7 +3,8 @@ from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parent.parent +from hermes_constants import get_hermes_source_root +REPO_ROOT = get_hermes_source_root() PYPROJECT = REPO_ROOT / "pyproject.toml" INSTALL_SH = REPO_ROOT / "scripts" / "install.sh" diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 72dc43564c..ef20917db9 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5,6 +5,7 @@ import sys import threading import time import types +import pytest from datetime import datetime from pathlib import Path from unittest.mock import patch diff --git a/tests/tools/test_windows_compat.py b/tests/tools/test_windows_compat.py index ec04d20959..7d048faaef 100644 --- a/tests/tools/test_windows_compat.py +++ b/tests/tools/test_windows_compat.py @@ -16,7 +16,8 @@ GUARDED_FILES = [ "gateway/platforms/whatsapp.py", ] -PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +from hermes_constants import get_hermes_source_root +PROJECT_ROOT = get_hermes_source_root() def _get_preexec_fn_values(filepath: Path) -> list: diff --git a/tools/browser_tool.py b/tools/browser_tool.py index 66684b8ee3..656e59f613 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -1803,7 +1803,8 @@ def _find_agent_browser() -> str: # WinError 193 "%1 is not a valid Win32 application". We must resolve to the # `.cmd` shim instead. `shutil.which` consults PATHEXT, so we delegate to it # with an explicit path so POSIX hosts still pick the extensionless shim. - repo_root = Path(__file__).parent.parent + from hermes_constants import get_hermes_source_root + repo_root = get_hermes_source_root() local_bin_dir = repo_root / "node_modules" / ".bin" if local_bin_dir.is_dir(): local_which = shutil.which("agent-browser", path=str(local_bin_dir)) diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 3b1c46ec3d..4247fd2fb4 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -17,6 +17,7 @@ from hermes_constants import display_hermes_home logger = logging.getLogger(__name__) # Import from cron module (will be available when properly installed) +# Bootstrap sys.path before imports — cannot use get_hermes_source_root() here yet. sys.path.insert(0, str(Path(__file__).parent.parent)) from cron.jobs import ( diff --git a/tools/lazy_deps.py b/tools/lazy_deps.py index bb5594033a..740967dae4 100644 --- a/tools/lazy_deps.py +++ b/tools/lazy_deps.py @@ -63,7 +63,6 @@ from pathlib import Path from typing import Any, Callable, Optional from hermes_constants import get_hermes_home -from hermes_cli.managed_uv import get_pip_cmd logger = logging.getLogger(__name__) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 5c3c46c4db..902b61e001 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -1358,9 +1358,9 @@ class MCPServerTask: raise ImportError( f"MCP server '{self.name}' requires the 'mcp' Python SDK, but " "it is not installed. Install with:\n" - " pip install 'hermes-agent[mcp]'\n" + " uv pip install -e '.[mcp]' # from the hermes-agent checkout\n" "or (full install):\n" - " pip install 'hermes-agent[all]'" + " uv pip install -e '.[all]' # from the hermes-agent checkout" ) command = config.get("command") diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index afa473e384..225eca8f51 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -1599,10 +1599,10 @@ async def _send_feishu(pconfig, chat_id, message, media_files=None, thread_id=No try: from gateway.platforms.feishu import FeishuAdapter, FEISHU_AVAILABLE if not FEISHU_AVAILABLE: - return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} + return {"error": "Feishu dependencies not installed. Run: uv pip install -e '.[feishu]' # from the hermes-agent checkout"} from gateway.platforms.feishu import FEISHU_DOMAIN, LARK_DOMAIN except ImportError: - return {"error": "Feishu dependencies not installed. Run: pip install 'hermes-agent[feishu]'"} + return {"error": "Feishu dependencies not installed. Run: uv pip install -e '.[feishu]' # from the hermes-agent checkout"} media_files = media_files or [] diff --git a/tools/skills_hub.py b/tools/skills_hub.py index 7750eb1b96..e349d126df 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -2924,10 +2924,10 @@ class OptionalSkillSource(SkillSource): """ def __init__(self): - from hermes_constants import get_optional_skills_dir + from hermes_constants import get_optional_skills_dir, get_hermes_source_root self._optional_dir = get_optional_skills_dir( - Path(__file__).parent.parent / "optional-skills" + get_hermes_source_root() / "optional-skills" ) def source_id(self) -> str: diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 99f79fc6a6..1d3e0c729c 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -57,12 +57,14 @@ def _get_bundled_dir() -> Path: then a wheel-installed data dir, then falls back to the relative path from this source file. """ - return get_bundled_skills_dir(Path(__file__).parent.parent / "skills") + from hermes_constants import get_hermes_source_root + return get_bundled_skills_dir(get_hermes_source_root() / "skills") def _get_optional_dir() -> Path: """Locate the official optional-skills/ directory.""" - return get_optional_skills_dir(Path(__file__).parent.parent / "optional-skills") + from hermes_constants import get_hermes_source_root + return get_optional_skills_dir(get_hermes_source_root() / "optional-skills") def _read_manifest() -> Dict[str, str]: diff --git a/tools/tts_tool.py b/tools/tts_tool.py index c6e7c22de0..d7e08fdfee 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -2174,7 +2174,7 @@ def text_to_speech_tool( return json.dumps({ "success": False, "error": "Mistral provider selected but 'mistralai' package not installed. " - "Run: pip install 'hermes-agent[mistral]'" + "Run: uv pip install -e '.[mistral]' # from the hermes-agent checkout" }, ensure_ascii=False) logger.info("Generating speech with Mistral Voxtral TTS...") _generate_mistral_tts(text, file_str, tts_config) diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 6561af2550..3a0ec7e9f0 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -6,7 +6,7 @@ sounddevice or system audio players. Dependencies (optional): pip install sounddevice numpy - or: pip install hermes-agent[voice] + or: uv pip install -e '.[voice]' # from the hermes-agent checkout """ import logging diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 7aedc0e781..bad8476342 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -36,7 +36,7 @@ logger = logging.getLogger(__name__) _hermes_home = get_hermes_home() load_hermes_dotenv( - hermes_home=_hermes_home, project_env=Path(__file__).parent.parent / ".env" + hermes_home=_hermes_home, project_env=__import__("hermes_constants").get_hermes_source_root() / ".env" )