feat: refactor doctor, unify pip install and project root
This commit is contained in:
parent
af1780f2ca
commit
f5f41a0921
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
2290
hermes_cli/doctor.py
2290
hermes_cli/doctor.py
File diff suppressed because it is too large
Load Diff
165
hermes_cli/doctor/__init__.py
Normal file
165
hermes_cli/doctor/__init__.py
Normal file
@ -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 <id>`
|
||||
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)
|
||||
94
hermes_cli/doctor/_output.py
Normal file
94
hermes_cli/doctor/_output.py
Normal file
@ -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()
|
||||
356
hermes_cli/doctor/_registry.py
Normal file
356
hermes_cli/doctor/_registry.py
Normal file
@ -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})",
|
||||
)
|
||||
65
hermes_cli/doctor/_types.py
Normal file
65
hermes_cli/doctor/_types.py
Normal file
@ -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)
|
||||
19
hermes_cli/doctor/checks/__init__.py
Normal file
19
hermes_cli/doctor/checks/__init__.py
Normal file
@ -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,
|
||||
)
|
||||
123
hermes_cli/doctor/checks/_helpers.py
Normal file
123
hermes_cli/doctor/checks/_helpers.py
Normal file
@ -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)
|
||||
304
hermes_cli/doctor/checks/api_connectivity.py
Normal file
304
hermes_cli/doctor/checks/api_connectivity.py
Normal file
@ -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 <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)
|
||||
67
hermes_cli/doctor/checks/auth_providers.py
Normal file
67
hermes_cli/doctor/checks/auth_providers.py
Normal file
@ -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"])
|
||||
84
hermes_cli/doctor/checks/command_install.py
Normal file
84
hermes_cli/doctor/checks/command_install.py
Normal file
@ -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,
|
||||
)
|
||||
334
hermes_cli/doctor/checks/config_files.py
Normal file
334
hermes_cli/doctor/checks/config_files.py
Normal file
@ -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_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)
|
||||
85
hermes_cli/doctor/checks/dep_mgmt.py
Normal file
85
hermes_cli/doctor/checks/dep_mgmt.py
Normal file
@ -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)")
|
||||
155
hermes_cli/doctor/checks/directory_structure.py
Normal file
155
hermes_cli/doctor/checks/directory_structure.py
Normal file
@ -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"
|
||||
"<!-- Edit this file to customize how Hermes communicates. -->\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)")
|
||||
274
hermes_cli/doctor/checks/external_tools.py
Normal file
274
hermes_cli/doctor/checks/external_tools.py
Normal file
@ -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)
|
||||
54
hermes_cli/doctor/checks/gateway_service.py
Normal file
54
hermes_cli/doctor/checks/gateway_service.py
Normal file
@ -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 <name>`")
|
||||
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}")
|
||||
36
hermes_cli/doctor/checks/github_check.py
Normal file
36
hermes_cli/doctor/checks/github_check.py
Normal file
@ -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)")
|
||||
73
hermes_cli/doctor/checks/memory_provider.py
Normal file
73
hermes_cli/doctor/checks/memory_provider.py
Normal file
@ -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")
|
||||
42
hermes_cli/doctor/checks/profiles.py
Normal file
42
hermes_cli/doctor/checks/profiles.py
Normal file
@ -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")
|
||||
71
hermes_cli/doctor/checks/python_env.py
Normal file
71
hermes_cli/doctor/checks/python_env.py
Normal file
@ -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)"
|
||||
),
|
||||
)
|
||||
47
hermes_cli/doctor/checks/security.py
Normal file
47
hermes_cli/doctor/checks/security.py
Normal file
@ -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)",
|
||||
)
|
||||
56
hermes_cli/doctor/checks/skills_hub.py
Normal file
56
hermes_cli/doctor/checks/skills_hub.py
Normal file
@ -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)")
|
||||
78
hermes_cli/doctor/checks/tool_availability.py
Normal file
78
hermes_cli/doctor/checks/tool_availability.py
Normal file
@ -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")
|
||||
24
hermes_cli/doctor/checks/xai_retirement.py
Normal file
24
hermes_cli/doctor/checks/xai_retirement.py
Normal file
@ -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}"
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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/<name>/...
|
||||
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
|
||||
|
||||
@ -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: <checkout>/.venv or <checkout>/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/<hash>-hermes-agent-python3.12-env
|
||||
- nix devshell: <checkout>/.venv
|
||||
Hermes is always invoked through its own venv wrapper, so sys.prefix
|
||||
reliably points to the right place.
|
||||
2. ``<source_root>/.venv`` if it exists on disk (pre-activation fallback).
|
||||
3. ``<source_root>/venv`` if it exists on disk.
|
||||
4. ``<source_root>/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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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())
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 = []
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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').
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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: [])
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):")
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user