feat: refactor doctor, unify pip install and project root

This commit is contained in:
ethernet 2026-06-12 15:04:05 -04:00
parent af1780f2ca
commit f5f41a0921
110 changed files with 3094 additions and 2697 deletions

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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 (

View File

@ -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)

View File

@ -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():

View File

@ -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

View File

@ -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]:

View File

@ -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")

View File

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

View File

@ -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

View File

@ -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

File diff suppressed because it is too large Load Diff

View 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)

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

View 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})",
)

View 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)

View 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,
)

View 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)

View 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)

View 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"])

View 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,
)

View 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)

View 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)")

View 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)")

View 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)

View 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}")

View 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)")

View 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")

View 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")

View 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)"
),
)

View 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)",
)

View 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)")

View 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")

View 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}"
)

View File

@ -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:

View File

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

View File

@ -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(

View File

@ -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 (~25 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

View File

@ -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)

View File

@ -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:

View File

@ -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}")

View File

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

View File

@ -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

View File

@ -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",

View File

@ -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,
)

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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.

View File

@ -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 —

View File

@ -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))

View File

@ -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(

View File

@ -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).

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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"
)

View File

@ -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

View File

@ -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)

View File

@ -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 = []

View File

@ -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"

View File

@ -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"

View File

@ -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').

View File

@ -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"

View File

@ -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

View File

@ -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")

View File

@ -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):

View File

@ -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"

View File

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

View File

@ -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:

View File

@ -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:

View File

@ -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(

View File

@ -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: [])

View File

@ -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 "

View File

@ -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):

View File

@ -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):")

View File

@ -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

View File

@ -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.

View File

@ -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))

View File

@ -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():

View File

@ -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"

View File

@ -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"

View File

@ -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():

View File

@ -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))

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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"

View File

@ -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:

View File

@ -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:

View File

@ -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)

View File

@ -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"

View File

@ -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

View File

@ -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