Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d3d44020 | ||
|
|
f5f41a0921 | ||
|
|
af1780f2ca | ||
|
|
74fbd7f01f | ||
|
|
afa3e89119 | ||
|
|
c542884168 | ||
|
|
5f298e5b2a | ||
|
|
4deaa42ccb | ||
|
|
4bdb2ba38c | ||
|
|
71bd99b8b0 | ||
|
|
04c8fcd1df | ||
|
|
e77f1de940 | ||
|
|
c64972af77 | ||
|
|
657bd1d328 | ||
|
|
8e291759fc | ||
|
|
02df5207a9 | ||
|
|
23ce5c00ad | ||
|
|
8ee19f354d | ||
|
|
f62abc9ac2 | ||
|
|
967574f9d7 | ||
|
|
3017095449 | ||
|
|
89be1f52b2 | ||
|
|
bfd6d165a7 |
@@ -26,6 +26,15 @@ reviewing any change:
|
||||
high. Most new capability should arrive as a CLI command + skill, a
|
||||
service-gated tool, or a plugin — not as core surface.
|
||||
|
||||
## Platform Support
|
||||
|
||||
Hermes Agent's platform support is formalized into three tiers. For the full tier breakdown, contribution guidelines, and user-facing migration policies, see the [Platform Support section in CONTRIBUTING.md](./CONTRIBUTING.md#platform-support) and the [Platform Support Reference](https://hermes-agent.nousresearch.com/docs/reference/platform-support).
|
||||
|
||||
**Summary for agents:**
|
||||
- **Explicitly Supported**: Linux (x86_64/arm64), macOS arm64, Windows (x86_64/arm64), Docker. Use `curl | bash`, PowerShell, or Docker installers.
|
||||
- **Best-Effort**: Termux, AUR, Homebrew, Nix. PRs accepted but won't block releases.
|
||||
- **Explicitly Unsupported**: macOS x86_64, pip/PyPI packaging, FreeBSD. Do not accept PRs for these.
|
||||
|
||||
## Contribution Rubric — What We Want / What We Don't
|
||||
|
||||
This is the project's intent layer. Use it two ways:
|
||||
@@ -123,6 +132,13 @@ conservative at the waist.
|
||||
without E2E proof, and plugins that touch core files.** Plugins live in their
|
||||
own directory and work within the ABCs/hooks we provide; if a plugin needs
|
||||
more, widen the generic plugin surface, don't special-case it in core.
|
||||
- **Dependency installation via uv helpers, not raw pip.** Hermes manages its
|
||||
own uv binary. Always use `ensure_uv()` or
|
||||
`resolve_uv()` from `hermes_cli.managed_uv`, or a dedicated helper like
|
||||
`_get_pip_cmd()` that returns `[uv_path, "pip"]`. **Do not** write
|
||||
`[sys.executable, "-m", "pip"]` or `[sys.executable, "-m", "ensurepip"]`
|
||||
directly in subprocess calls. The helpers guarantee the managed uv path and
|
||||
only fall back to raw pip in explicitly documented degenerate edge cases.
|
||||
|
||||
### Before you call it a bug — verify the premise (and when NOT to close)
|
||||
|
||||
|
||||
+33
-1
@@ -9,7 +9,7 @@ Thank you for contributing to Hermes Agent! This guide covers everything you nee
|
||||
We value contributions in this order:
|
||||
|
||||
1. **Bug fixes** — crashes, incorrect behavior, data loss. Always top priority.
|
||||
2. **Cross-platform compatibility** — macOS, different Linux distros, and WSL2 on Windows. We want Hermes to work everywhere.
|
||||
2. **Cross-platform compatibility** — See the [Platform Support](#platform-support) section below for tier-aware guidelines.
|
||||
3. **Security hardening** — shell injection, prompt injection, path traversal, privilege escalation. See [Security](#security-considerations).
|
||||
4. **Performance and robustness** — retry logic, error handling, graceful degradation.
|
||||
5. **New skills** — but only broadly useful ones. See [Should it be a Skill or a Tool?](#should-it-be-a-skill-or-a-tool)
|
||||
@@ -18,6 +18,38 @@ We value contributions in this order:
|
||||
|
||||
---
|
||||
|
||||
## Platform Support
|
||||
|
||||
Hermes Agent's platform support is formalized into three tiers. This ensures we can maintain high quality and reliability while still welcoming community contributions.
|
||||
|
||||
### Explicitly Supported (Guaranteed)
|
||||
These platforms are fully supported, tested, and guaranteed to work. We provide first-party installers and prioritize fixes for these environments.
|
||||
|
||||
| Platform | Supported Installers |
|
||||
|----------|----------------------|
|
||||
| Linux (x86_64 / arm64) | `curl \| bash` installer, Docker image |
|
||||
| Latest Debian, Ubuntu, Fedora | `curl \| bash` installer |
|
||||
| Official Docker image | `docker pull` |
|
||||
| macOS (arm64 / Apple Silicon) | Desktop app installer, `curl \| bash` installer |
|
||||
| Windows (x86_64 / arm64) | Desktop app installer, PowerShell installer |
|
||||
|
||||
### Best-Effort Support
|
||||
We welcome community PRs for fixes on these platforms, and they generally work, but Nous will not prioritize them. We also do not accept packaging-specific code changes into the core repository for these platforms.
|
||||
|
||||
- **Termux / Android**: Community-supported. Best-effort fixes are welcome, but will not block Hermes releases.
|
||||
- **AUR Packaging**: Community-maintained.
|
||||
- **Homebrew Packaging**: Deprecated. See the [Platform Support docs](https://hermes-agent.nousresearch.com/docs/reference/platform-support) for migration.
|
||||
- **Nix Packaging**: The `flake.nix` and NixOS module are maintained in-tree as a primary deployment method. However, niche Nix-specific packaging bugs (e.g., a new dependency failing to build under Nix) are treated as best-effort.
|
||||
|
||||
### Explicitly Unsupported
|
||||
We do not accept PRs attempting to add or restore support for these platforms.
|
||||
|
||||
- **macOS (x86_64 / Intel)**: No longer supported.
|
||||
- **Packaging via pip / PyPI**: Deprecated and discontinued.
|
||||
- **FreeBSD**: Not supported.
|
||||
|
||||
---
|
||||
|
||||
## Should it be a Skill or a Tool?
|
||||
|
||||
This is the most common question for new contributors. The answer is almost always **skill**.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
@@ -343,8 +343,11 @@ 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(
|
||||
[sys.executable, "-m", "pip", "install", "--target", str(pip_target), "--quiet", pkg],
|
||||
get_pip_cmd() + ["install", "--target", str(pip_target), "--quiet", pkg],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -8881,7 +8881,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
)
|
||||
raise RuntimeError(
|
||||
"Voice mode requires sounddevice and numpy.\n"
|
||||
f"Install with: {sys.executable} -m pip install sounddevice numpy"
|
||||
f"Install with: uv pip install sounddevice numpy"
|
||||
)
|
||||
if not reqs.get("stt_available", reqs.get("stt_key_set")):
|
||||
raise RuntimeError(
|
||||
@@ -9185,7 +9185,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
_cprint(f" {_DIM}Then install/update the Termux:API Android app for microphone capture{_RST}")
|
||||
_cprint(f" {_BOLD}Option 2: pkg install python-numpy portaudio && python -m pip install sounddevice{_RST}")
|
||||
else:
|
||||
_cprint(f"\n {_BOLD}Install: {sys.executable} -m pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
_cprint(f"\n {_BOLD}Install: uv pip install {' '.join(reqs['missing_packages'])}{_RST}")
|
||||
return
|
||||
|
||||
with self._voice_lock:
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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 (
|
||||
|
||||
+6
-4
@@ -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)
|
||||
|
||||
@@ -9392,7 +9394,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
if "pynacl" in err_lower or "nacl" in err_lower or "davey" in err_lower:
|
||||
return (
|
||||
"Voice dependencies are missing (PyNaCl / davey). "
|
||||
f"Install with: `{sys.executable} -m pip install PyNaCl`"
|
||||
f"Install with: `uv pip install PyNaCl`"
|
||||
)
|
||||
return f"Failed to join voice channel: {e}"
|
||||
|
||||
|
||||
@@ -3431,7 +3431,8 @@ class GatewaySlashCommandsMixin:
|
||||
if is_managed():
|
||||
return f"✗ {format_managed_message('update Hermes Agent')}"
|
||||
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
git_dir = project_root / '.git'
|
||||
|
||||
if not git_dir.exists():
|
||||
|
||||
@@ -268,7 +268,8 @@ def check_for_updates() -> Optional[int]:
|
||||
# Prefer the running code's location over the profile-scoped path.
|
||||
# $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all;
|
||||
# Path(__file__) always resolves to the actual installed checkout.
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_dir = get_hermes_source_root()
|
||||
if not (repo_dir / ".git").exists():
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
if not (repo_dir / ".git").exists():
|
||||
@@ -293,7 +294,8 @@ def _resolve_repo_dir() -> Optional[Path]:
|
||||
because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried
|
||||
over by ``--clone-all``.
|
||||
"""
|
||||
repo_dir = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_dir = get_hermes_source_root()
|
||||
if not (repo_dir / ".git").exists():
|
||||
hermes_home = get_hermes_home()
|
||||
repo_dir = hermes_home / "hermes-agent"
|
||||
@@ -728,17 +730,15 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
except Exception:
|
||||
pass # Never break the banner over an update check
|
||||
|
||||
# Pip-install warning — `pip install hermes-agent` is not the supported
|
||||
# install path (it exists on PyPI for internal/CI reasons, not end users).
|
||||
# Such installs miss the git checkout + installer-managed deps, so updates,
|
||||
# self-update, and issue triage don't behave correctly. Warn, don't block.
|
||||
# PyPI install warning — `pip install hermes-agent` is not a supported
|
||||
# install path. Direct users to the official installer.
|
||||
try:
|
||||
from hermes_cli.config import detect_install_method
|
||||
if detect_install_method() == "pip":
|
||||
right_lines.append(
|
||||
"[bold yellow]⚠ pip install not officially supported[/]"
|
||||
"[dim yellow] — exists for reasons other than user install; "
|
||||
"expect instability and an inability to support issues[/]"
|
||||
"[bold yellow]⚠ the hermes-agent python package is no longer supported[/]"
|
||||
"[dim yellow] please reinstall via our official installer "
|
||||
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash[/]"
|
||||
)
|
||||
except Exception:
|
||||
pass # Never break the banner over the install-method check
|
||||
|
||||
@@ -27,10 +27,11 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
# Path is resolved relative to this module so it works regardless of cwd —
|
||||
# matches the pattern used by ``banner._resolve_repo_dir``.
|
||||
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
|
||||
_BUILD_SHA_FILE = get_hermes_source_root() / ".hermes_build_sha"
|
||||
|
||||
|
||||
def get_build_sha(short: int = 8) -> Optional[str]:
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ from hermes_cli.setup import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
_OPENCLAW_SCRIPT = (
|
||||
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
|
||||
|
||||
@@ -379,7 +379,8 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
|
||||
if managed:
|
||||
return managed.lower().replace(" ", "-")
|
||||
if project_root is None:
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
if (project_root / ".git").is_dir():
|
||||
return "git"
|
||||
return "pip"
|
||||
@@ -435,9 +436,10 @@ def recommended_update_command_for_method(method: str) -> str:
|
||||
if is_uv_tool_install():
|
||||
return "uv tool upgrade hermes-agent"
|
||||
import shutil
|
||||
if shutil.which("uv"):
|
||||
return "uv pip install --upgrade hermes-agent"
|
||||
return "pip install --upgrade hermes-agent"
|
||||
if shutil.which("pipx") and "pipx" in __import__("sys").prefix.split(__import__("os").sep):
|
||||
return "pipx upgrade hermes-agent"
|
||||
# PyPI-based installs are no longer supported — direct to the installer.
|
||||
return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
|
||||
return "hermes update"
|
||||
|
||||
|
||||
@@ -600,9 +602,6 @@ def get_env_path() -> Path:
|
||||
"""Get the .env file path (for API keys)."""
|
||||
return get_hermes_home() / ".env"
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
|
||||
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
|
||||
@@ -5931,6 +5930,8 @@ def redact_key(key: str) -> str:
|
||||
|
||||
def show_config():
|
||||
"""Display current configuration."""
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
config = load_config()
|
||||
|
||||
print()
|
||||
@@ -5943,7 +5944,7 @@ def show_config():
|
||||
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
|
||||
print(f" Config: {get_config_path()}")
|
||||
print(f" Secrets: {get_env_path()}")
|
||||
print(f" Install: {get_project_root()}")
|
||||
print(f" Install: {get_hermes_source_root()}")
|
||||
|
||||
# API Keys
|
||||
print()
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -14,12 +14,14 @@ automatically.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import requests
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -163,17 +165,13 @@ def _ensure_qrcode_installed() -> bool:
|
||||
|
||||
import subprocess
|
||||
|
||||
# Try uv first (Hermes convention), then pip
|
||||
for cmd in (
|
||||
[sys.executable, "-m", "uv", "pip", "install", "qrcode"],
|
||||
[sys.executable, "-m", "pip", "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
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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})",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"])
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -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)")
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)"
|
||||
),
|
||||
)
|
||||
@@ -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)",
|
||||
)
|
||||
@@ -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)")
|
||||
@@ -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")
|
||||
@@ -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}"
|
||||
)
|
||||
+4
-4
@@ -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:
|
||||
|
||||
+13
-29
@@ -15,7 +15,8 @@ import textwrap
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
from gateway.status import terminate_pid
|
||||
from gateway.restart import (
|
||||
@@ -2139,34 +2140,15 @@ def get_launchd_plist_path() -> Path:
|
||||
def _detect_venv_dir() -> Path | None:
|
||||
"""Detect the active virtualenv directory.
|
||||
|
||||
Checks ``sys.prefix`` first (works regardless of the directory name),
|
||||
then ``VIRTUAL_ENV`` env var (covers uv-managed environments where
|
||||
sys.prefix == sys.base_prefix), then falls back to probing common
|
||||
directory names under PROJECT_ROOT.
|
||||
Returns ``None`` when no virtualenv can be found.
|
||||
Delegates to ``get_venv_path()`` from ``hermes_cli.managed_uv``,
|
||||
which is the single source of truth for locating the project venv.
|
||||
Returns ``None`` only when the resolved path doesn't exist on disk
|
||||
(pre-install state).
|
||||
"""
|
||||
# If we're running inside a virtualenv, sys.prefix points to it.
|
||||
if sys.prefix != sys.base_prefix:
|
||||
venv = Path(sys.prefix)
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
from hermes_cli.managed_uv import get_venv_path
|
||||
p = get_venv_path()
|
||||
return p if p.is_dir() else None
|
||||
|
||||
# uv and some other tools set VIRTUAL_ENV without changing sys.prefix.
|
||||
# This catches `uv run` where sys.prefix == sys.base_prefix but the
|
||||
# environment IS a venv. (#8620)
|
||||
_virtual_env = os.environ.get("VIRTUAL_ENV")
|
||||
if _virtual_env:
|
||||
venv = Path(_virtual_env)
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
# Fallback: check common virtualenv directory names under the project root.
|
||||
for candidate in (".venv", "venv"):
|
||||
venv = PROJECT_ROOT / candidate
|
||||
if venv.is_dir():
|
||||
return venv
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_python_path() -> str:
|
||||
@@ -2355,7 +2337,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
|
||||
python_path = get_python_path()
|
||||
working_dir = _stable_service_working_dir()
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
|
||||
|
||||
path_entries = _build_service_path_dirs()
|
||||
resolved_node = shutil.which("node")
|
||||
@@ -3193,7 +3176,8 @@ def generate_launchd_plist() -> str:
|
||||
# the systemd unit), then capture the user's full shell PATH so every
|
||||
# user-installed tool (node, ffmpeg, …) is reachable.
|
||||
detected_venv = _detect_venv_dir()
|
||||
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
|
||||
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
|
||||
# so it's explicitly in PATH even if the user's shell PATH changes later.
|
||||
priority_dirs = _build_service_path_dirs()
|
||||
|
||||
@@ -183,7 +183,8 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None
|
||||
if extra_args:
|
||||
args.extend(extra_args)
|
||||
params = subprocess.list2cmdline(args)
|
||||
cwd = str(Path(__file__).resolve().parent.parent)
|
||||
from hermes_constants import get_hermes_source_root
|
||||
cwd = str(get_hermes_source_root())
|
||||
elevated_python = _derive_venv_pythonw(sys.executable)
|
||||
try:
|
||||
result = ctypes.windll.shell32.ShellExecuteW(
|
||||
|
||||
+81
-132
@@ -320,7 +320,8 @@ def _require_tty(command_name: str) -> None:
|
||||
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
@@ -4516,10 +4517,10 @@ def _nixos_build_env() -> dict[str, str] | None:
|
||||
return None
|
||||
|
||||
# Tier 1: fast path — hermes venv python3, no nix-shell overhead
|
||||
for venv_name in ("venv", ".venv"):
|
||||
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
|
||||
if venv_python.exists():
|
||||
return {**os.environ, "PYTHON": str(venv_python)}
|
||||
from hermes_cli.managed_uv import get_venv_path as _gvp
|
||||
venv_python = _gvp() / "bin" / "python3"
|
||||
if venv_python.exists():
|
||||
return {**os.environ, "PYTHON": str(venv_python)}
|
||||
|
||||
# Tier 2: nix-shell fallback — resolves the absolute python3 path once.
|
||||
# Slower (~2–5 s for the nix-shell eval) but always works, even without
|
||||
@@ -5784,35 +5785,14 @@ def _update_via_zip(args):
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
uv_bin = _ensure_uv_for_termux()
|
||||
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
_install_python_dependencies_with_optional_fallback([uv_bin, "pip"], env=uv_env)
|
||||
_install_python_dependencies_with_optional_fallback()
|
||||
else:
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
|
||||
# Some environments lose pip inside the venv; bootstrap it back with
|
||||
# ensurepip before trying the editable install.
|
||||
try:
|
||||
subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd)
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
_install_python_dependencies_with_optional_fallback(group="termux-all" if _is_termux_env() else "all")
|
||||
|
||||
_update_node_dependencies()
|
||||
_build_web_ui(PROJECT_ROOT / "web")
|
||||
@@ -6429,10 +6409,9 @@ def _recover_from_interrupted_install() -> None:
|
||||
|
||||
Triggered on launch when ``.update-incomplete`` is present — meaning the
|
||||
code was pulled but the dep install was killed before it verified clean.
|
||||
Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``
|
||||
can wipe pip from the venv entirely, which blocks the venv from recovering
|
||||
on its own), then re-runs the editable ``.[all]`` install + core-dependency
|
||||
verification, then clears the marker.
|
||||
Re-runs the editable ``.[all]`` install via the managed ``uv`` binary
|
||||
(guaranteed by ``ensure_uv()``), falls back to plain pip if degraded,
|
||||
runs core-dependency verification, then clears the marker.
|
||||
|
||||
Never raises: a recovery failure must not block launch. If it can't
|
||||
self-heal it prints the one-line manual command and leaves the marker so
|
||||
@@ -6500,32 +6479,16 @@ def _recover_from_interrupted_install() -> None:
|
||||
try:
|
||||
from hermes_cli.managed_uv import ensure_uv
|
||||
|
||||
# Always bootstrap pip first: a killed install can leave the venv with
|
||||
# no pip module at all, and uv may also be gone. ensurepip restores a
|
||||
# known-good pip so at least the plain-pip path below can proceed.
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
capture_output=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("ensurepip during install recovery failed: %s", exc)
|
||||
|
||||
# ensure_uv() guarantees the managed uv binary is present, bootstrapping
|
||||
# it via the official installer if a killed install removed it.
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[uv_bin, "pip"],
|
||||
env=uv_env,
|
||||
group="termux-all" if _is_termux_env(uv_env) else "all",
|
||||
group="termux-all" if _is_termux_env() else "all",
|
||||
)
|
||||
else:
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[sys.executable, "-m", "pip"],
|
||||
group="termux-all" if _is_termux_env() else "all",
|
||||
)
|
||||
|
||||
@@ -6536,10 +6499,10 @@ def _recover_from_interrupted_install() -> None:
|
||||
# the exact manual recovery command in the meantime.
|
||||
logger.debug("Interrupted-install recovery failed: %s", exc)
|
||||
print("✗ Could not auto-recover the interrupted install.")
|
||||
print(" Recover manually with:")
|
||||
print(" Recover manually by ensuring uv is installed, then run:")
|
||||
print(f" cd {PROJECT_ROOT}")
|
||||
print(f" {sys.executable} -m ensurepip --upgrade")
|
||||
print(f" {sys.executable} -m pip install -e '.[all]'")
|
||||
print(" uv pip install -e '.[all]'")
|
||||
print(" (Or re-run the Hermes installer: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash)")
|
||||
finally:
|
||||
sys.stdout = saved_sys_stdout
|
||||
if saved_stdout_fd is not None:
|
||||
@@ -7038,7 +7001,6 @@ def _refresh_active_lazy_features() -> None:
|
||||
|
||||
|
||||
def _install_python_dependencies_with_optional_fallback(
|
||||
install_cmd_prefix: list[str],
|
||||
*,
|
||||
env: dict[str, str] | None = None,
|
||||
group: str = "all",
|
||||
@@ -7052,7 +7014,14 @@ def _install_python_dependencies_with_optional_fallback(
|
||||
in the venv Scripts dir before each install attempt so uv can write fresh
|
||||
copies (Windows blocks REPLACE on a running .exe but allows RENAME). See
|
||||
``_quarantine_running_hermes_exe`` for the rationale.
|
||||
|
||||
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
|
||||
|
||||
install_cmd_prefix = get_pip_cmd()
|
||||
scripts_dir = _venv_scripts_dir() if _is_windows() else None
|
||||
|
||||
def _install(args: list[str]) -> None:
|
||||
@@ -7321,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:
|
||||
@@ -7350,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
|
||||
@@ -7373,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
|
||||
@@ -8027,12 +7997,19 @@ 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, update_managed_uv
|
||||
|
||||
@@ -8040,46 +8017,33 @@ def _cmd_update_pip(args):
|
||||
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 = [sys.executable, "-m", "pip", "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)
|
||||
@@ -8087,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``."""
|
||||
@@ -8499,51 +8464,36 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
update_managed_uv()
|
||||
|
||||
uv_bin = ensure_uv()
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
if not uv_bin:
|
||||
uv_bin = _ensure_uv_for_termux(pip_cmd)
|
||||
install_group = "all"
|
||||
|
||||
if uv_bin:
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
|
||||
if _is_termux_env(uv_env):
|
||||
uv_env.pop("PYTHONPATH", None)
|
||||
uv_env.pop("PYTHONHOME", None)
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using uv + curated termux-all optional profile...")
|
||||
if _is_termux_env(uv_env) and _is_android_python():
|
||||
print(" → Termux/Android detected: prebuilding psutil with Linux source path compatibility...")
|
||||
_install_psutil_android_compat([uv_bin, "pip"], env=uv_env)
|
||||
_install_python_dependencies_with_optional_fallback(
|
||||
[uv_bin, "pip"], env=uv_env, group=install_group
|
||||
)
|
||||
else:
|
||||
# Use sys.executable to explicitly call the venv's pip module,
|
||||
# avoiding PEP 668 'externally-managed-environment' errors on Debian/Ubuntu.
|
||||
# Some environments lose pip inside the venv; bootstrap it back with
|
||||
# ensurepip before trying the editable install.
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
except subprocess.CalledProcessError:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
cwd=PROJECT_ROOT,
|
||||
check=True,
|
||||
)
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
print(" → Termux detected: using curated termux-all optional profile...")
|
||||
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(pip_cmd)
|
||||
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
|
||||
_install_psutil_android_compat()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
else:
|
||||
# Degenerate fallback: managed uv failed to install.
|
||||
uv_bin = ensure_uv()
|
||||
if uv_bin:
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
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()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
else:
|
||||
# Ultimate degenerate fallback: no uv at all.
|
||||
if _is_termux_env():
|
||||
install_group = "termux-all"
|
||||
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()
|
||||
_install_python_dependencies_with_optional_fallback(group=install_group)
|
||||
|
||||
# Core Python deps installed AND verified (the fallback helper runs
|
||||
# _verify_core_dependencies_installed). Clear the interrupted-install
|
||||
@@ -10248,10 +10198,9 @@ def cmd_dashboard(args):
|
||||
except ImportError as e:
|
||||
print("Web UI dependencies not installed (need fastapi + uvicorn).")
|
||||
print(
|
||||
f"Re-install the package into this interpreter so metadata updates apply:\n"
|
||||
f"Re-install the package using uv so metadata updates apply:\n"
|
||||
f" cd {PROJECT_ROOT}\n"
|
||||
f" {sys.executable} -m pip install -e .\n"
|
||||
"If `pip` is missing in this venv, use: uv pip install -e ."
|
||||
f" uv pip install -e .\n"
|
||||
)
|
||||
print(f"Import error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -15,6 +15,7 @@ import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
@@ -250,5 +251,213 @@ def _install_uv_windows(env: dict[str, str]) -> None:
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
def get_pip_cmd() -> list[str]:
|
||||
"""Return the authoritative pip command prefix.
|
||||
|
||||
Hermes strictly requires `uv` for dependency management.
|
||||
Fallback hierarchy:
|
||||
1. Managed uv at `$HERMES_HOME/bin/uv` (guaranteed by `ensure_uv()`).
|
||||
2. System/PATH `uv` (e.g., Termux `pkg install uv`, Homebrew, etc.).
|
||||
|
||||
If neither is found, this raises a RuntimeError. We NEVER fall back to
|
||||
raw `[sys.executable, "-m", "pip"]`, as that re-introduces the
|
||||
ensurepip/PEP-668/venv-contamination bugs this architecture was built to eliminate.
|
||||
"""
|
||||
uv_bin = resolve_uv()
|
||||
if uv_bin:
|
||||
return [uv_bin, "pip"]
|
||||
|
||||
# Secondary fallback: check PATH for uv (critical for Termux `pkg install uv` support)
|
||||
path_uv = shutil.which("uv")
|
||||
if path_uv:
|
||||
return [path_uv, "pip"]
|
||||
|
||||
# HARD FAIL: uv is a strict requirement. Do not silently degrade to raw pip.
|
||||
raise RuntimeError(
|
||||
"uv is not installed or not found in PATH. "
|
||||
"Hermes strictly requires uv for dependency management. "
|
||||
"Please run `hermes doctor` to diagnose and fix your environment, "
|
||||
"or install uv manually (e.g., `pkg install uv` on Termux, or via the Hermes installer)."
|
||||
)
|
||||
|
||||
|
||||
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`
|
||||
(e.g., `/path/to/venv/bin/python` -> `/path/to/venv`).
|
||||
"""
|
||||
if sys.prefix != sys.base_prefix:
|
||||
return Path(sys.prefix)
|
||||
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],
|
||||
*,
|
||||
venv_root: Optional[Path] = None,
|
||||
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
|
||||
(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)
|
||||
|
||||
env = {**os.environ}
|
||||
env["VIRTUAL_ENV"] = str(venv_root)
|
||||
|
||||
# Ensure venv bin is first in PATH
|
||||
venv_bin = str(venv_root / "bin")
|
||||
env["PATH"] = f"{venv_bin}{os.pathsep}{env.get('PATH', '')}"
|
||||
|
||||
# Clean up PYTHONPATH/PYTHONHOME to avoid venv contamination
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
cmd,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
# Synthesize a failure result so callers can handle it uniformly
|
||||
return subprocess.CompletedProcess(
|
||||
args=cmd,
|
||||
returncode=1,
|
||||
stdout="",
|
||||
stderr=str(e),
|
||||
)
|
||||
|
||||
|
||||
def recreate_venv_atomically(project_root: Path, group: str = "all") -> bool:
|
||||
"""Atomically recreate the venv to ensure a clean, uv-native state.
|
||||
|
||||
This is the safest way to migrate from a legacy pip-created venv or
|
||||
repair a corrupted venv. It builds a fresh `venv.new`, installs dependencies,
|
||||
and then atomically swaps `venv` -> `venv.bak` and `venv.new` -> `venv`.
|
||||
|
||||
This guarantees we never accidentally strip dependencies or leave legacy
|
||||
pip cruft behind, as we are building a pristine environment from scratch.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
target_venv = project_root / "venv"
|
||||
new_venv = project_root / "venv.new"
|
||||
backup_venv = project_root / "venv.bak"
|
||||
|
||||
uv_bin = resolve_uv() or shutil.which("uv")
|
||||
if not uv_bin:
|
||||
logger.error("Cannot recreate venv: uv is not installed or found in PATH.")
|
||||
return False
|
||||
|
||||
print(f" → Creating fresh venv at {new_venv}...")
|
||||
# 1. Create fresh venv
|
||||
res = subprocess.run(
|
||||
[uv_bin, "venv", str(new_venv)],
|
||||
capture_output=True, text=True, timeout=120
|
||||
)
|
||||
if res.returncode != 0:
|
||||
logger.error("Failed to create new venv: %s", res.stderr)
|
||||
return False
|
||||
|
||||
print(f" → Installing dependencies into new venv ({group})...")
|
||||
# 2. Install dependencies into the new venv
|
||||
env = {**os.environ, "VIRTUAL_ENV": str(new_venv)}
|
||||
env["PATH"] = f"{new_venv / 'bin'}{os.pathsep}{env.get('PATH', '')}"
|
||||
env.pop("PYTHONPATH", None)
|
||||
env.pop("PYTHONHOME", None)
|
||||
|
||||
res = subprocess.run(
|
||||
[uv_bin, "pip", "install", "-e", f".[{group}]"],
|
||||
cwd=project_root,
|
||||
capture_output=True, text=True, timeout=600,
|
||||
env=env, stdin=subprocess.DEVNULL
|
||||
)
|
||||
if res.returncode != 0:
|
||||
logger.error("Failed to install dependencies in new venv: %s", res.stderr)
|
||||
# Clean up failed new venv
|
||||
shutil.rmtree(new_venv, ignore_errors=True)
|
||||
return False
|
||||
|
||||
print(" -> Dependencies installed successfully. Performing atomic swap...")
|
||||
# 3. Atomic swap
|
||||
try:
|
||||
if target_venv.exists():
|
||||
if backup_venv.exists():
|
||||
shutil.rmtree(backup_venv, ignore_errors=True)
|
||||
target_venv.rename(backup_venv)
|
||||
|
||||
new_venv.rename(target_venv)
|
||||
print(" OK Venv successfully recreated and swapped.")
|
||||
print(" -> (Old venv backed up to venv.bak. You can safely delete it if everything works.)")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Failed to atomically swap venvs: %s", e)
|
||||
# Attempt to restore if swap failed mid-way
|
||||
if not target_venv.exists() and backup_venv.exists():
|
||||
backup_venv.rename(target_venv)
|
||||
return False
|
||||
|
||||
|
||||
def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool:
|
||||
True # dont remove me. ask ethernet
|
||||
@@ -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:
|
||||
|
||||
@@ -8,10 +8,13 @@ the provider's config schema. Writes config to config.yaml + .env.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.secret_prompt import masked_secret_prompt
|
||||
|
||||
@@ -96,33 +99,15 @@ def _install_dependencies(provider_name: str) -> None:
|
||||
|
||||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||
|
||||
import shutil
|
||||
|
||||
uv_path = shutil.which("uv")
|
||||
if uv_path:
|
||||
install_cmd = [uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing
|
||||
manual_cmd = f"uv pip install --python {sys.executable} {' '.join(missing)}"
|
||||
else:
|
||||
pip_cmd = shutil.which("pip3") or shutil.which("pip")
|
||||
if not pip_cmd:
|
||||
print(f" ⚠ uv not found — cannot install dependencies")
|
||||
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||
print(f" Then re-run: hermes memory setup")
|
||||
return
|
||||
print(f" ⚠ uv not found. Falling back to standard pip...")
|
||||
install_cmd = [sys.executable, "-m", "pip", "install", "--quiet"] + missing
|
||||
manual_cmd = f"{sys.executable} -m pip install {' '.join(missing)}"
|
||||
|
||||
manual_cmd = f"uv pip install {' '.join(missing)}"
|
||||
try:
|
||||
subprocess.run(
|
||||
install_cmd,
|
||||
check=True, timeout=120,
|
||||
capture_output=True,
|
||||
)
|
||||
result = pip_install(missing, quiet=True, timeout=120)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args, result.stdout, result.stderr)
|
||||
print(f" ✓ Installed {', '.join(missing)}")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" ⚠ Failed to install {', '.join(missing)}")
|
||||
stderr = (e.stderr or b"").decode()[:200]
|
||||
stderr = (e.stderr or b"").decode()[:200] if isinstance(e.stderr, bytes) else (e.stderr or "")[:200]
|
||||
if stderr:
|
||||
print(f" {stderr}")
|
||||
print(f" Run manually: {manual_cmd}")
|
||||
|
||||
@@ -151,10 +151,11 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
|
||||
|
||||
def _has_agent_browser() -> bool:
|
||||
import shutil
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
agent_browser_bin = shutil.which("agent-browser")
|
||||
local_bin = (
|
||||
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
|
||||
get_hermes_source_root() / "node_modules" / ".bin" / "agent-browser"
|
||||
)
|
||||
return bool(agent_browser_bin or local_bin.exists())
|
||||
|
||||
|
||||
@@ -62,7 +62,8 @@ def get_bundled_plugins_dir() -> Path:
|
||||
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
|
||||
if env_override:
|
||||
return Path(env_override)
|
||||
return Path(__file__).resolve().parent.parent / "plugins"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
return get_hermes_source_root() / "plugins"
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
@@ -900,7 +900,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
|
||||
"user_modified": [],
|
||||
"skipped_opt_out": True,
|
||||
}
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c",
|
||||
|
||||
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
|
||||
def _print_aiohttp_missing() -> None:
|
||||
print(
|
||||
"hermes proxy requires aiohttp. Install one of:\n"
|
||||
" pip install 'hermes-agent[messaging]'\n"
|
||||
" uv pip install -e '.[messaging]' # from the hermes-agent checkout\n"
|
||||
" pip install aiohttp",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
@@ -86,7 +86,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"aiohttp is required for `hermes proxy`. Install with: "
|
||||
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
|
||||
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
|
||||
)
|
||||
|
||||
app = web.Application()
|
||||
@@ -253,7 +253,7 @@ async def run_server(
|
||||
if not AIOHTTP_AVAILABLE:
|
||||
raise RuntimeError(
|
||||
"aiohttp is required for `hermes proxy`. Install with: "
|
||||
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
|
||||
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
|
||||
)
|
||||
|
||||
app = create_app(adapter)
|
||||
|
||||
+11
-23
@@ -16,11 +16,13 @@ import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import copy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
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
|
||||
@@ -28,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"
|
||||
|
||||
@@ -796,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(
|
||||
[sys.executable, "-m", "pip", "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:
|
||||
@@ -821,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(
|
||||
[sys.executable, "-m", "pip", "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:
|
||||
@@ -1285,11 +1284,7 @@ def setup_terminal_backend(config: dict):
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "modal"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = pip_install(["modal"])
|
||||
if result.returncode == 0:
|
||||
print_success("modal SDK installed")
|
||||
else:
|
||||
@@ -1338,11 +1333,7 @@ def setup_terminal_backend(config: dict):
|
||||
text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "daytona"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = pip_install(["daytona"])
|
||||
if result.returncode == 0:
|
||||
print_success("daytona SDK installed")
|
||||
else:
|
||||
@@ -1988,10 +1979,7 @@ def _setup_matrix():
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", matrix_pkg],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
result = pip_install([matrix_pkg])
|
||||
if result.returncode == 0:
|
||||
print_success(f"{matrix_pkg} installed")
|
||||
else:
|
||||
|
||||
@@ -9,7 +9,8 @@ import sys
|
||||
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
|
||||
from hermes_cli.auth import AuthError, resolve_provider
|
||||
from hermes_cli.colors import Colors, color
|
||||
|
||||
+13
-75
@@ -24,6 +24,7 @@ from hermes_cli.config import (
|
||||
load_config, save_config, get_env_value, save_env_value,
|
||||
)
|
||||
from hermes_cli.colors import Colors, color
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_cli.nous_subscription import (
|
||||
apply_nous_managed_defaults,
|
||||
get_nous_subscription_features,
|
||||
@@ -34,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) ────────────────────────────────────────
|
||||
@@ -580,73 +582,10 @@ def _cua_driver_cmd() -> str:
|
||||
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
|
||||
|
||||
|
||||
def _pip_install(
|
||||
args: List[str],
|
||||
*,
|
||||
timeout: int = 300,
|
||||
capture_output: bool = True,
|
||||
):
|
||||
"""Install Python packages from a post-setup hook.
|
||||
|
||||
Strategy (in order):
|
||||
1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the venv.
|
||||
2. ``python -m pip install`` — works on stdlib venvs.
|
||||
3. ``python -m ensurepip --upgrade`` then retry pip — covers ``uv venv``
|
||||
which creates a venv WITHOUT pip.
|
||||
|
||||
Why this exists: the Windows installer creates the venv via ``uv venv``,
|
||||
which doesn't seed pip. Post-setup hooks that shelled out to
|
||||
``[sys.executable, '-m', 'pip', 'install', ...]`` failed with
|
||||
``No module named pip`` on every fresh install. uv-first sidesteps that.
|
||||
|
||||
Returns the ``subprocess.CompletedProcess`` from whichever tier succeeded
|
||||
(or the last failure for the caller to inspect).
|
||||
"""
|
||||
venv_root = Path(sys.executable).parent.parent
|
||||
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
|
||||
|
||||
uv_bin = shutil.which("uv")
|
||||
if uv_bin:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[uv_bin, "pip", "install", *args],
|
||||
capture_output=capture_output, text=True, timeout=timeout,
|
||||
env=uv_env,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Fall through to pip — uv may have failed for an unrelated reason
|
||||
# (resolution conflict, network), and pip might handle it.
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
pip_cmd = [sys.executable, "-m", "pip"]
|
||||
try:
|
||||
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
|
||||
probe = subprocess.run(
|
||||
pip_cmd + ["--version"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
if probe.returncode != 0:
|
||||
raise FileNotFoundError("pip not in venv")
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
try:
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
|
||||
capture_output=True, text=True, timeout=120, check=True,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
|
||||
# Synthesize a result so callers see a clean failure path.
|
||||
return subprocess.CompletedProcess(
|
||||
pip_cmd, returncode=1, stdout="",
|
||||
stderr=f"pip not available and ensurepip failed: {e}",
|
||||
)
|
||||
|
||||
return subprocess.run(
|
||||
pip_cmd + ["install", *args],
|
||||
capture_output=capture_output, text=True, timeout=timeout,
|
||||
)
|
||||
|
||||
def pip_install_deps(packages: list[str], *, timeout: int = 300, quiet: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Thin wrapper around the authoritative managed_uv.pip_install for tools_config."""
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
return pip_install(packages, timeout=timeout, quiet=quiet)
|
||||
|
||||
|
||||
def _check_cua_driver_asset_for_arch() -> bool:
|
||||
@@ -833,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")
|
||||
@@ -840,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
|
||||
@@ -908,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.
|
||||
@@ -951,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.
|
||||
@@ -987,7 +925,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
"0.8.1/kittentts-0.8.1-py3-none-any.whl"
|
||||
)
|
||||
try:
|
||||
result = _pip_install(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", wheel_url, "soundfile", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" kittentts installed")
|
||||
_print_info(" Voices: Jasper, Bella, Luna, Bruno, Rosie, Hugo, Kiki, Leo")
|
||||
@@ -1007,7 +945,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
except ImportError:
|
||||
_print_info(" Installing piper-tts (~14MB wheel, voices downloaded on first use)...")
|
||||
try:
|
||||
result = _pip_install(["-U", "piper-tts", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", "piper-tts", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" piper-tts installed")
|
||||
else:
|
||||
@@ -1030,7 +968,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
except ImportError:
|
||||
_print_info(" Installing ddgs (DuckDuckGo search package)...")
|
||||
try:
|
||||
result = _pip_install(["-U", "ddgs", "--quiet"], timeout=300)
|
||||
result = pip_install_deps(["-U", "ddgs", "--quiet"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
_print_success(" ddgs installed")
|
||||
else:
|
||||
@@ -1081,7 +1019,7 @@ def _run_post_setup(post_setup_key: str):
|
||||
_print_success(" langfuse SDK already installed")
|
||||
except ImportError:
|
||||
_print_info(" Installing langfuse SDK...")
|
||||
result = _pip_install(["langfuse", "--quiet"], timeout=120)
|
||||
result = pip_install_deps(["langfuse", "--quiet"], timeout=120)
|
||||
if result.returncode == 0:
|
||||
_print_success(" langfuse SDK installed")
|
||||
else:
|
||||
|
||||
@@ -25,9 +25,7 @@ def log_success(msg: str):
|
||||
def log_warn(msg: str):
|
||||
print(f"{color('⚠', Colors.YELLOW)} {msg}")
|
||||
|
||||
def get_project_root() -> Path:
|
||||
"""Get the project installation directory."""
|
||||
return Path(__file__).parent.parent.resolve()
|
||||
|
||||
|
||||
|
||||
def find_shell_configs() -> list:
|
||||
@@ -572,7 +570,8 @@ def run_uninstall(args):
|
||||
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
|
||||
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
|
||||
"""
|
||||
project_root = get_project_root()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
project_root = get_hermes_source_root()
|
||||
hermes_home = get_hermes_home()
|
||||
|
||||
# Detect named profiles when uninstalling from the default root —
|
||||
|
||||
@@ -37,7 +37,8 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
@@ -84,7 +85,7 @@ except ImportError:
|
||||
except Exception:
|
||||
raise SystemExit(
|
||||
"Web UI requires fastapi and uvicorn.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'"
|
||||
f"Install with: uv pip install 'fastapi' 'uvicorn[standard]'"
|
||||
)
|
||||
|
||||
WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist"
|
||||
|
||||
@@ -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(
|
||||
|
||||
+2
-2
@@ -452,7 +452,7 @@ def create_mcp_server(event_bridge: Optional[EventBridge] = None) -> "FastMCP":
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
raise ImportError(
|
||||
"MCP server requires the 'mcp' package. "
|
||||
f"Install with: {sys.executable} -m pip install 'mcp'"
|
||||
f"Install with: uv pip install 'mcp'"
|
||||
)
|
||||
|
||||
mcp = FastMCP(
|
||||
@@ -868,7 +868,7 @@ def run_mcp_server(verbose: bool = False) -> None:
|
||||
if not _MCP_SERVER_AVAILABLE:
|
||||
print(
|
||||
"Error: MCP server requires the 'mcp' package.\n"
|
||||
f"Install with: {sys.executable} -m pip install 'mcp'",
|
||||
f"Install with: uv pip install 'mcp'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
# ⚠️ DEPRECATED
|
||||
|
||||
This Homebrew formula is **discontinued and no longer receives updates**.
|
||||
Please use a supported installation method (curl installer, Docker, or Nix).
|
||||
See https://hermes-agent.nousresearch.com/ for details.
|
||||
|
||||
---
|
||||
|
||||
Homebrew packaging notes for Hermes Agent.
|
||||
|
||||
Use `packaging/homebrew/hermes-agent.rb` as a tap or `homebrew-core` starting point.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# FROZEN: This formula is deprecated and will not receive further updates.
|
||||
# pip/PyPI and Homebrew installations are discontinued.
|
||||
# See https://hermes-agent.nousresearch.com/ for supported install methods.
|
||||
|
||||
class HermesAgent < Formula
|
||||
include Language::Python::Virtualenv
|
||||
|
||||
desc "Self-improving AI agent that creates skills from experience"
|
||||
homepage "https://hermes-agent.nousresearch.com"
|
||||
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
|
||||
# Stable source should point at the semver-named sdist asset attached by
|
||||
# scripts/release.py, not the CalVer tag tarball.
|
||||
url "https://github.com/NousResearch/hermes-agent/releases/download/v2026.3.30/hermes_agent-0.6.0.tar.gz"
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
# Platform Support Tiers — Implementation Plan
|
||||
|
||||
## Goal
|
||||
|
||||
Formalize Hermes Agent's platform support into three tiers, document them in the repo and docs, and deprecate removed platforms (pip/PyPI, Homebrew formula) with a clear migration path.
|
||||
|
||||
---
|
||||
|
||||
## Platform Support Tiers
|
||||
|
||||
### Explicitly supported — guaranteed to work, first-party installers only
|
||||
|
||||
| Platform | Installer |
|
||||
|----------|-----------|
|
||||
| Linux x86_64 / arm64 | `curl \| bash` installer, Docker image |
|
||||
| Latest Debian, Ubuntu, Fedora, Windows WSL | `curl \| bash` installer |
|
||||
| Official Docker image | `docker pull` |
|
||||
| macOS arm64 | Desktop app installer, `curl \| bash` installer |
|
||||
| Windows x86_64 / arm64 | Desktop app installer, PowerShell installer |
|
||||
|
||||
### Best-effort — PRs accepted for fixes, but Nous won't prioritize them, nor will we accept packaging-specific code in the repo
|
||||
|
||||
- Termux / Android
|
||||
- AUR packaging
|
||||
- Homebrew packaging
|
||||
- Nix packaging (flake + NixOS module stay in-tree and maintained; packaging bugs outside core nix support are best-effort)
|
||||
|
||||
### Explicitly unsupported — no PRs for support will be accepted
|
||||
|
||||
- macOS x86_64
|
||||
- Packaging via pip / PyPI
|
||||
- FreeBSD
|
||||
|
||||
---
|
||||
|
||||
## Where the tier information lives
|
||||
|
||||
### In the repo (for agents and contributors)
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `AGENTS.md` | New `## Platform Support` section with the tier table and a link to the docs reference page. This is the canonical in-repo source — agents and contributors read it first. |
|
||||
| `CONTRIBUTING.md` | Update the "Contribution Priorities" section (currently lines 8–17) to reference the tiers. Replace the generic "Cross-platform compatibility" bullet with explicit tier-aware language: PRs for best-effort platforms are welcome but won't block releases; PRs for unsupported platforms will be closed. |
|
||||
|
||||
### In the docs (for users)
|
||||
|
||||
| File | What to add |
|
||||
|------|-------------|
|
||||
| `website/docs/reference/platform-support.md` (new) | The canonical user-facing reference. Full tier breakdown with nuance, migration instructions for each deprecated path, and a support policy summary. Other pages link here. |
|
||||
| `website/docs/getting-started/installation.md` | Add a prominent tier summary (table or callout) near the top, before the install commands. Remove the pip install row from the install layout table. Add a "Migrating from pip" subsection. |
|
||||
| `website/docs/getting-started/updating.md` | Replace the "pip installs" update section with a deprecation notice linking to the platform-support page. |
|
||||
| `website/docs/getting-started/termux.md` | Add a best-effort support banner at the top. |
|
||||
| `website/docs/getting-started/nix-setup.md` | Add a note clarifying that the nix flake + NixOS module are maintained in-tree but nix-specific packaging bugs are best-effort. |
|
||||
|
||||
### What does NOT change
|
||||
|
||||
- `flake.nix`, `nix/`, Dockerfile — these are deployment methods, not just packaging. They stay.
|
||||
- `scripts/install.sh` termux detection — already works, no reason to break it.
|
||||
- `constraints-termux.txt` and `[termux]`/`[termux-all]` extras in pyproject.toml — still needed for best-effort users.
|
||||
|
||||
---
|
||||
|
||||
## Deprecating removed platforms
|
||||
|
||||
### 1. pip / PyPI
|
||||
|
||||
**Step 1: Publish one final version to PyPI**
|
||||
|
||||
- Update the package `description` in `pyproject.toml` to include a deprecation prefix:
|
||||
```
|
||||
⚠️ DEPRECATED: pip/PyPI installs are discontinued. See https://hermes-agent.nousresearch.com/ for supported install methods.
|
||||
```
|
||||
- Bump the version (next semver, e.g. `0.17.0`)
|
||||
- Cut the release through the existing pipeline (`scripts/release.py` → tag push → `upload_to_pypi.yml`)
|
||||
- After that release ships, **disable `upload_to_pypi.yml`**: add `if: false` to the job definitions and a comment explaining why
|
||||
|
||||
**Step 2: Add runtime deprecation notices**
|
||||
|
||||
Every touchpoint where Hermes detects a pip install must surface a clear deprecation warning. The message should be consistent across all surfaces:
|
||||
|
||||
> ⚠️ pip/PyPI installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
|
||||
|
||||
In `hermes_cli/`:
|
||||
|
||||
| Location | Change |
|
||||
|----------|--------|
|
||||
| `config.py` — `detect_install_method()` returns `"pip"` | Keep returning `"pip"` (detection still works, needed so existing installs see the deprecation message) |
|
||||
| `config.py` — `cmd_update` pip path | Replace the `uv pip install --upgrade hermes-agent` command with the deprecation message above. Do not attempt the upgrade — just print the message and exit. |
|
||||
| `banner.py` — existing `detect_install_method() == "pip"` check | Add a deprecation line to the startup banner for pip installs |
|
||||
| `main.py` — `hermes doctor` | Print the deprecation warning when `detect_install_method() == "pip"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
|
||||
|
||||
**Step 3: Update the docs**
|
||||
|
||||
- `website/docs/getting-started/installation.md`: remove the pip install row from the install layout table; add a deprecation callout box; add a brief "Migrating from pip" section
|
||||
- `website/docs/getting-started/updating.md`: replace the "pip installs" section with a deprecation notice + link
|
||||
|
||||
**Step 4: Clean up pyproject.toml and enforce build failure (after the final release)**
|
||||
|
||||
- Add a comment at the top of `[project]` noting that PyPI publishing is discontinued
|
||||
- Remove `[project.scripts]` entries — they're only needed for pip's `console_scripts` entry points; git/docker/nix all use their own launchers
|
||||
- Keep `[build-system]`, `[project.optional-dependencies]`, and `[tool.setuptools]` sections — they're used by nix build and local dev setup (like termux), not just pip
|
||||
- Remove `hermes_agent.egg-info/` from tracking
|
||||
- **Enforce wheel build failure**: replace any remaining `setup.py` with a minimal stub that explicitly raises a `RuntimeError("pip/wheel builds are discontinued. Please use curl install, docker, or nix. See https://hermes-agent.nousresearch.com/")`. This prevents accidental silent fallback builds.
|
||||
- **Standardize on uv**: update all remaining local dev/build documentation, scripts, and comments to explicitly use `uv pip` instead of plain `pip`.
|
||||
|
||||
**Step 5: Rip out `ensurepip` and standardize entirely on `uv`**
|
||||
|
||||
Since the `curl | bash` installer and all supported environments guarantee a working `uv` binary, remove all legacy `ensurepip` bootstrapping and plain `pip` fallback logic across the codebase. Any remaining local dependency provisioning (e.g., in dev setups, update recovery, or tool environments like Modal) must strictly use `uv pip`. This eliminates race conditions, partial installs, and state confusion from legacy pip bootstrapping.
|
||||
- Print a hard deprecation message instead of attempting any `pip install` commands in `config.py` / `main.py` update paths.
|
||||
- `is_uv_tool_install()` detection can stay (it's used internally to differentiate from source/nix/docker builds).
|
||||
|
||||
### 2. Homebrew
|
||||
|
||||
**Step 1: Deprecate the formula**
|
||||
|
||||
- In `packaging/homebrew/hermes-agent.rb`, add Homebrew's official deprecation:
|
||||
```ruby
|
||||
deprecate! because: "is discontinued upstream. See https://hermes-agent.nousresearch.com/ for supported install methods."
|
||||
```
|
||||
- Bump the formula `url`/`version`/`sha256` one final time to match the last release
|
||||
|
||||
**Step 2: Add runtime deprecation notices**
|
||||
|
||||
Every touchpoint where Hermes detects a Homebrew install must surface a clear deprecation warning. The message should be consistent across all surfaces:
|
||||
|
||||
> ⚠️ Homebrew installs are discontinued and no longer receive updates. Switch to a supported install method: https://hermes-agent.nousresearch.com/
|
||||
|
||||
In `hermes_cli/`:
|
||||
|
||||
| Location | Change |
|
||||
|----------|--------|
|
||||
| `config.py` — `get_managed_update_command()` | Return the deprecation message above instead of `"brew upgrade hermes-agent"`. Do not suggest running brew upgrade. |
|
||||
| `config.py` — `format_managed_message()` | Prepend the deprecation notice before any Homebrew-specific managed-install messages. |
|
||||
| `banner.py` | Add a deprecation line to the startup banner when `get_managed_system() == "Homebrew"`. |
|
||||
| `main.py` — `hermes doctor` | Print the deprecation warning when `detect_install_method() == "homebrew"`, with an additional line: "Migrate with: curl -fsSL https://hermes-agent.nousresearch.com/install.sh \| bash" |
|
||||
|
||||
**Step 3: Mark the formula as frozen**
|
||||
|
||||
- Add a comment at the top of `hermes-agent.rb` saying it's frozen and won't receive further updates
|
||||
- Update `packaging/homebrew/README.md` to say the formula is deprecated
|
||||
- Keep the directory in-tree as a reference — don't delete it
|
||||
|
||||
### 3. AUR
|
||||
|
||||
- No in-tree code. Purely a social/docs change.
|
||||
- Add a note in `website/docs/reference/platform-support.md` saying AUR packaging is community-maintained and best-effort.
|
||||
- No code changes needed.
|
||||
|
||||
### 4. Nix (stays in-tree, boundary clarified)
|
||||
|
||||
- `flake.nix` and `nix/` stay in-tree and maintained — they're a deployment method, not just packaging
|
||||
- Add a comment in `flake.nix` and `nix/packages.nix` clarifying that nix-specific packaging bugs (e.g. a new dependency that doesn't build under nix) are best-effort: PRs accepted, but won't block releases
|
||||
- The flake's `systems` list already excludes `x86_64-darwin` (only `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`) — that's correct for the new tiers, no change needed
|
||||
|
||||
### 5. Termux
|
||||
|
||||
- Add a best-effort support banner to `website/docs/getting-started/termux.md`
|
||||
- `constraints-termux.txt`, `[termux]`/`[termux-all]` extras, and `scripts/install.sh` termux detection all stay
|
||||
- Termux-specific bugs won't block releases
|
||||
|
||||
### 6. macOS x86_64
|
||||
|
||||
- Add a runtime warning: if `platform.machine() == 'x86_64'` and `sys.platform == 'darwin'`, print a one-time deprecation notice in `hermes doctor` saying the platform is unsupported
|
||||
- Don't actively break anything — just set expectations
|
||||
|
||||
---
|
||||
|
||||
## Execution order
|
||||
|
||||
1. Add `## Platform Support` section to `AGENTS.md`
|
||||
2. Create `website/docs/reference/platform-support.md`
|
||||
3. Update `CONTRIBUTING.md` to reference the tiers
|
||||
4. Update `website/docs/getting-started/installation.md` with tier info and pip deprecation
|
||||
5. Update `website/docs/getting-started/updating.md` with pip deprecation
|
||||
6. Add runtime deprecation notices in `hermes_cli/config.py`, `hermes_cli/banner.py`, `hermes_cli/main.py` for **both pip and Homebrew** installs
|
||||
7. Update `packaging/homebrew/hermes-agent.rb` with `deprecate!`
|
||||
8. Cut the final PyPI release with deprecation description in pyproject.toml
|
||||
9. After the release: disable `upload_to_pypi.yml`, replace pip update command with deprecation message, add deprecation comments to pyproject.toml, remove `[project.scripts]`
|
||||
10. Rip out all `ensurepip` and legacy `pip` fallback logic across `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts, standardizing exclusively on `uv pip`.
|
||||
11. Add termux best-effort banner to termux docs
|
||||
12. Add macOS x86_64 unsupported warning to `hermes doctor`
|
||||
|
||||
---
|
||||
|
||||
## Files touched (summary)
|
||||
|
||||
| File | Action |
|
||||
|------|--------|
|
||||
| `AGENTS.md` | Add platform support section |
|
||||
| `CONTRIBUTING.md` | Update contribution priorities with tier awareness |
|
||||
| `website/docs/reference/platform-support.md` | Create — canonical user-facing tier reference |
|
||||
| `website/docs/getting-started/installation.md` | Add tier summary, pip deprecation, migration section |
|
||||
| `website/docs/getting-started/updating.md` | Replace pip section with deprecation |
|
||||
| `website/docs/getting-started/termux.md` | Add best-effort banner |
|
||||
| `website/docs/getting-started/nix-setup.md` | Add best-effort boundary note |
|
||||
| `hermes_cli/config.py` | Deprecation messages for pip update + Homebrew update command |
|
||||
| `hermes_cli/banner.py` | Deprecation line for pip installs + Homebrew installs |
|
||||
| `hermes_cli/main.py` | `hermes doctor` pip + Homebrew deprecation warnings, macOS x86_64 warning |
|
||||
| `hermes_cli/tools_config.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `tools/lazy_deps.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `tools/environments/modal.py` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `scripts/install.ps1` | Rip out `ensurepip` fallback, standardize on `uv pip` for local provisioning |
|
||||
| `packaging/homebrew/hermes-agent.rb` | Add `deprecate!`, freeze comment |
|
||||
| `packaging/homebrew/README.md` | Mark as deprecated |
|
||||
| `pyproject.toml` | Deprecation description, later: remove `[project.scripts]`, add comments |
|
||||
| `.github/workflows/upload_to_pypi.yml` | Disable after final release (`if: false`) |
|
||||
|
||||
---
|
||||
|
||||
## Full removal (future, after deprecation period)
|
||||
|
||||
After a suitable deprecation period (suggested: 2–3 minor releases, or ~6 months), fully remove the deprecated code and packaging infrastructure. This is a separate PR to avoid breaking existing installs prematurely.
|
||||
|
||||
### pip / PyPI — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `hermes_cli/config.py` — `detect_install_method()` | Remove the `"pip"` return path entirely. If no stamp, no managed marker, and no `.git`, treat it as an unknown install rather than defaulting to pip. |
|
||||
| `hermes_cli/config.py` — `cmd_update` pip path | Remove the pip-specific update branch. |
|
||||
| `hermes_cli/config.py` — `_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries. |
|
||||
| `hermes_cli/config.py` — `get_managed_update_command()` | Remove the `Homebrew` branch. |
|
||||
| `hermes_cli/config.py` — `format_managed_message()` | Remove the `Homebrew` branch. |
|
||||
| `hermes_cli/banner.py` | Remove pip and Homebrew deprecation lines from the banner. |
|
||||
| `hermes_cli/main.py` — `hermes doctor` | Remove pip and Homebrew deprecation warnings. |
|
||||
| `pyproject.toml` — `[project.optional-dependencies]` | Keep the `termux` and `termux-all` extras (local source builds via uv/nix still use them for best-effort support). Remove the `pty` and `vision` back-compat aliases (they were legacy pip-only install targets). |
|
||||
| `pyproject.toml` — `[project]` | Remove `description` deprecation prefix. |
|
||||
| `setup.py` | Replace with a minimal stub that raises a `RuntimeError` explaining pip/wheel builds are discontinued (prevents silent fallback builds). Move any skills/optional-skills data-file logic into the nix build if nix still needs it. |
|
||||
| `hermes_agent.egg-info/` | Delete entirely. |
|
||||
| `.github/workflows/upload_to_pypi.yml` | Delete the workflow file. |
|
||||
| `constraints-termux.txt` | Remove — termux users build from source and can maintain their own constraints. |
|
||||
| `scripts/install_psutil_android.py` | Remove — termux-specific pip hack. |
|
||||
| `tests/test_packaging_metadata.py` | Remove pip-specific assertions (wheel/sdist packaging tests). |
|
||||
| `tests/test_termux_all_extra_compat.py` | Remove. |
|
||||
| `tests/test_wheel_locales_e2e.py` | Remove — tests pip wheel install behavior. |
|
||||
| `tests/hermes_cli/test_cmd_update.py` — pip regression tests | Remove the `"pip"` parameterized test cases. |
|
||||
| `tests/hermes_cli/test_cmd_update_docker.py` — pip test case | Remove the `test_cmd_update_check_on_pip_install_still_uses_pypi` test. |
|
||||
|
||||
### Homebrew — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `packaging/homebrew/` | Delete the entire directory (formula + README). The formula is frozen and will never be updated again — no reason to keep it. |
|
||||
| `hermes_cli/config.py` — `_MANAGED_SYSTEM_NAMES` | Remove `"brew"` and `"homebrew"` entries (listed above, duplicate for clarity). |
|
||||
| `pyproject.toml` — `[project.optional-dependencies]` comments | Remove Homebrew-specific comments (e.g. the `voice` extra comment about "source-build packagers like Homebrew"). |
|
||||
| `pyproject.toml` — `[all]` policy comment | Remove the "packagers (Nix, AUR, Homebrew)" references, update to just "packagers (Nix, AUR)". |
|
||||
|
||||
### macOS x86_64 — full removal
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| `hermes_cli/main.py` — `hermes doctor` | Remove the x86_64 macOS warning (or escalate to a hard error that refuses to start). |
|
||||
| `flake.nix` — `systems` | Already correct (no `x86_64-darwin`). No change needed. |
|
||||
|
||||
### General cleanup
|
||||
|
||||
- **Rip out all `ensurepip` and `pip` fallback logic**: Search the codebase for `ensurepip`, `-m pip`, and `pip install`. Update `hermes_cli/main.py`, `hermes_cli/tools_config.py`, `tools/lazy_deps.py`, `tools/environments/modal.py`, and install scripts to exclusively use `uv pip` for *any* remaining local environment provisioning.
|
||||
- Search the codebase for any remaining references to `"pip"`, `"homebrew"`, `"brew"`, `PyPI`, `pypi.org`, `upload_to_pypi`, `egg-info`, and `setup.py` — remove or update them.
|
||||
- Run the full test suite to confirm nothing is broken by the removals.
|
||||
- Update `AGENTS.md` and `website/docs/reference/platform-support.md` to remove any "deprecated" language and state the removed paths as simply unsupported (no longer "deprecated and still detected" — just gone).
|
||||
@@ -13,10 +13,12 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from hermes_cli.managed_uv import pip_install
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
from plugins.google_meet import process_manager as pm
|
||||
@@ -249,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(
|
||||
[sys.executable, "-m", "pip", "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).
|
||||
|
||||
@@ -7,9 +7,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
@@ -410,14 +412,8 @@ def _ensure_sdk_installed() -> bool:
|
||||
print(" Skipping install. Run: pip install 'honcho-ai>=2.0.1'\n")
|
||||
return False
|
||||
|
||||
import subprocess
|
||||
print(" Installing honcho-ai...", flush=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
result = pip_install(["honcho-ai>=2.0.1"])
|
||||
if result.returncode == 0:
|
||||
print(" Installed.\n")
|
||||
return True
|
||||
|
||||
@@ -6604,7 +6604,7 @@ def register(ctx) -> None:
|
||||
check_fn=check_discord_requirements,
|
||||
is_connected=_is_connected,
|
||||
required_env=["DISCORD_BOT_TOKEN"],
|
||||
install_hint="pip install 'hermes-agent[messaging]'",
|
||||
install_hint="uv pip install -e '.[messaging]' # from the hermes-agent checkout",
|
||||
# Interactive setup wizard — replaces the central
|
||||
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
|
||||
setup_fn=interactive_setup,
|
||||
|
||||
@@ -3299,7 +3299,7 @@ def register(ctx) -> None:
|
||||
"GOOGLE_CHAT_SUBSCRIPTION_NAME",
|
||||
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
|
||||
],
|
||||
install_hint="pip install 'hermes-agent[google_chat]'",
|
||||
install_hint="uv pip install -e '.[google_chat]' # from the hermes-agent checkout",
|
||||
setup_fn=interactive_setup,
|
||||
# Env-driven auto-configuration — the core env-populator hook calls
|
||||
# this during ``_apply_env_overrides`` and seeds
|
||||
|
||||
@@ -68,7 +68,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple
|
||||
|
||||
# Pin the legacy logger name so operator-side log filters keep matching
|
||||
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")
|
||||
|
||||
@@ -379,16 +380,15 @@ def install_deps() -> bool:
|
||||
|
||||
print("Installing Google Chat OAuth dependencies...")
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet"] + _REQUIRED_PACKAGES,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
result = pip_install(_REQUIRED_PACKAGES, quiet=True)
|
||||
if result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(result.returncode, result.args)
|
||||
print("Dependencies installed.")
|
||||
return True
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(f"ERROR: Failed to install dependencies: {exc}")
|
||||
print("Or install via the optional extra:")
|
||||
print(" pip install 'hermes-agent[google_chat]'")
|
||||
print(" uv pip install -e '.[google_chat]' # from the hermes-agent checkout")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
|
||||
from hermes_constants import get_hermes_source_root
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -46,7 +47,7 @@ _discovered = False
|
||||
|
||||
# Repo-root ``plugins/model-providers/`` — populated at discovery time.
|
||||
_BUNDLED_PLUGINS_DIR = (
|
||||
Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
|
||||
get_hermes_source_root() / "plugins" / "model-providers"
|
||||
)
|
||||
|
||||
|
||||
|
||||
+82
-16
@@ -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
|
||||
@@ -338,11 +402,13 @@ addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
|
||||
python-version = "3.13"
|
||||
|
||||
[tool.ty.rules]
|
||||
all = "warn"
|
||||
unknown-argument = "warn"
|
||||
redundant-cast = "ignore"
|
||||
unresolved-reference = "error"
|
||||
|
||||
[tool.ruff]
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
|
||||
[tool.ruff.lint]
|
||||
# All other lints are intentionally disabled (see comment history on this
|
||||
|
||||
@@ -37,7 +37,8 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
|
||||
SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE)
|
||||
|
||||
|
||||
@@ -135,7 +135,8 @@ def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
|
||||
|
||||
def main() -> int:
|
||||
fix_mode = "--fix" in sys.argv
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_root = get_hermes_source_root()
|
||||
os.chdir(repo_root)
|
||||
|
||||
all_violations = []
|
||||
|
||||
+44
-79
@@ -8,12 +8,12 @@
|
||||
# iex (irm https://hermes-agent.nousresearch.com/install.ps1)
|
||||
#
|
||||
# Or download and run with options:
|
||||
# .\install.ps1 -NoVenv -SkipSetup
|
||||
# .\install.ps1 -SkipSetup
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
param(
|
||||
[switch]$NoVenv,
|
||||
|
||||
[switch]$SkipSetup,
|
||||
[string]$Branch = "main",
|
||||
# -Commit and -Tag are higher-precedence variants of -Branch for users
|
||||
@@ -1360,11 +1360,6 @@ function Install-Repository {
|
||||
}
|
||||
|
||||
function Install-Venv {
|
||||
if ($NoVenv) {
|
||||
Write-Info "Skipping virtual environment (-NoVenv)"
|
||||
return
|
||||
}
|
||||
|
||||
Write-Info "Creating virtual environment with Python $PythonVersion..."
|
||||
|
||||
Push-Location $InstallDir
|
||||
@@ -1398,11 +1393,9 @@ function Install-Dependencies {
|
||||
Write-Info "Installing dependencies..."
|
||||
|
||||
Push-Location $InstallDir
|
||||
|
||||
if (-not $NoVenv) {
|
||||
# Tell uv to install into our venv (no activation needed)
|
||||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||||
}
|
||||
|
||||
# Tell uv to install into our venv (no activation needed)
|
||||
$env:VIRTUAL_ENV = "$InstallDir\venv"
|
||||
|
||||
# Re-pin UV_PYTHON to the venv interpreter. Install-Venv already does this,
|
||||
# but the bootstrap runs install stages (venv, python-deps) as separate
|
||||
@@ -1411,11 +1404,9 @@ function Install-Dependencies {
|
||||
# Without it, an inherited $env:UV_PYTHON = "3.14" makes the uv sync/pip
|
||||
# tiers below recreate the venv at 3.14 and fail the maturin source build
|
||||
# (no cp314 wheels yet).
|
||||
if (-not $NoVenv) {
|
||||
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPythonExe) {
|
||||
$env:UV_PYTHON = $venvPythonExe
|
||||
}
|
||||
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
|
||||
if (Test-Path $venvPythonExe) {
|
||||
$env:UV_PYTHON = $venvPythonExe
|
||||
}
|
||||
|
||||
# Hash-verified install (Tier 0) -- when uv.lock is present, prefer
|
||||
@@ -1482,22 +1473,22 @@ function Install-Dependencies {
|
||||
|
||||
# Parse [project.optional-dependencies].all from pyproject.toml.
|
||||
# tomllib is stdlib on Python 3.11+ which the bootstrap guarantees.
|
||||
$pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
|
||||
$pythonExeForParse = "$InstallDir\venv\Scripts\python.exe"
|
||||
$allExtras = @()
|
||||
if (Test-Path $pythonExeForParse) {
|
||||
$parsed = & $pythonExeForParse -c @"
|
||||
import re, sys, tomllib
|
||||
try:
|
||||
with open('pyproject.toml', 'rb') as fh:
|
||||
data = tomllib.load(fh)
|
||||
specs = data['project']['optional-dependencies']['all']
|
||||
out = []
|
||||
for s in specs:
|
||||
m = re.search(r'hermes-agent\[([\w-]+)\]', s)
|
||||
if m: out.append(m.group(1))
|
||||
print(','.join(out))
|
||||
with open('pyproject.toml', 'rb') as fh:
|
||||
data = tomllib.load(fh)
|
||||
specs = data['project']['optional-dependencies']['all']
|
||||
out = []
|
||||
for s in specs:
|
||||
m = re.search(r'hermes-agent\[([\w-]+)\]', s)
|
||||
if m: out.append(m.group(1))
|
||||
print(','.join(out))
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
sys.exit(1)
|
||||
"@ 2>$null
|
||||
if ($LASTEXITCODE -eq 0 -and $parsed) {
|
||||
$allExtras = $parsed.Trim().Split(',')
|
||||
@@ -1541,17 +1532,16 @@ except Exception:
|
||||
# `ModuleNotFoundError: No module named 'dotenv'` on first run).
|
||||
# We probe via the venv's own python so a misdirected sync is caught
|
||||
# here, not 30 seconds later when the user runs `hermes`.
|
||||
if (-not $NoVenv) {
|
||||
$venvPython = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $venvPython)) {
|
||||
throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
|
||||
}
|
||||
# Relax EAP=Stop while running the import probe. Python writes
|
||||
# deprecation warnings and import-system info to stderr; under
|
||||
# EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and
|
||||
# throws even when the imports succeed. $LASTEXITCODE is the
|
||||
# reliable signal (it's 0 iff the python invocation exited 0,
|
||||
# regardless of what was written to stderr).
|
||||
$venvPython = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $venvPython)) {
|
||||
throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, manually: cd '$InstallDir'; Remove-Item -Recurse -Force venv,.venv; uv venv venv --python $PythonVersion; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
|
||||
}
|
||||
# Relax EAP=Stop while running the import probe. Python writes
|
||||
# deprecation warnings and import-system info to stderr; under
|
||||
# EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and
|
||||
# throws even when the imports succeed. $LASTEXITCODE is the
|
||||
# reliable signal (it's 0 iff the python invocation exited 0,
|
||||
# regardless of what was written to stderr).
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
& $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null
|
||||
@@ -1573,7 +1563,7 @@ except Exception:
|
||||
# users hit and lazy-import errors from `hermes dashboard` are confusing.
|
||||
# If tier 1 failed (the common case), [web] was still picked up by tiers
|
||||
# 2-3; only tier 4 leaves you without it.
|
||||
$pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
|
||||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (Test-Path $pythonExe) {
|
||||
$webOk = $false
|
||||
# Relax EAP=Stop while running the import probe; see the matching
|
||||
@@ -1599,20 +1589,16 @@ except Exception:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Pop-Location
|
||||
|
||||
|
||||
Write-Success "All dependencies installed"
|
||||
}
|
||||
|
||||
function Set-PathVariable {
|
||||
Write-Info "Setting up hermes command..."
|
||||
|
||||
if ($NoVenv) {
|
||||
$hermesBin = "$InstallDir"
|
||||
} else {
|
||||
$hermesBin = "$InstallDir\venv\Scripts"
|
||||
}
|
||||
$hermesBin = "$InstallDir\venv\Scripts"
|
||||
|
||||
# Add the venv Scripts dir to user PATH so hermes is globally available
|
||||
# On Windows, the hermes.exe in venv\Scripts\ has the venv Python baked in
|
||||
@@ -2398,19 +2384,11 @@ function Install-PlatformSdks {
|
||||
# 1. The tiered `uv pip install` cascade above can fall through to a
|
||||
# lower tier when the first fails (common when RL git deps choke),
|
||||
# which silently skips some messaging SDKs from [messaging].
|
||||
# 2. `uv` creates the venv without pip. If a messaging SDK ends up
|
||||
# missing, the user can't `pip install python-telegram-bot` to
|
||||
# recover -- pip simply isn't in their venv.
|
||||
# 2. Missing SDKs should be installed via the managed `uv` binary, which
|
||||
# handles dependency resolution cleanly without needing pip in the venv.
|
||||
#
|
||||
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
|
||||
# for each token set in .env, verify the matching SDK imports. If not,
|
||||
# run one targeted `pip install` as last-chance recovery. Keeps fresh
|
||||
# Windows installs from hitting silent "python-telegram-bot not installed"
|
||||
# at runtime.
|
||||
if ($NoVenv) {
|
||||
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
|
||||
return
|
||||
}
|
||||
# Strategy: for each token set in .env, verify the matching SDK imports.
|
||||
# If not, run one targeted `uv pip install` as last-chance recovery.
|
||||
|
||||
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
|
||||
if (-not (Test-Path $pythonExe)) {
|
||||
@@ -2470,29 +2448,18 @@ function Install-PlatformSdks {
|
||||
}
|
||||
if ($missing.Count -eq 0) { return }
|
||||
|
||||
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
|
||||
# without pip; ensurepip is the stdlib-blessed way to add it.
|
||||
# Use the managed uv binary to install the missing SDKs.
|
||||
# We do not need to bootstrap pip into the venv; uv handles this directly.
|
||||
$prevEAP = $ErrorActionPreference
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
try {
|
||||
& $pythonExe -m pip --version 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..."
|
||||
& $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Warn "ensurepip failed -- can't auto-install missing SDKs."
|
||||
Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($sdk in $missing) {
|
||||
Write-Info " Installing $($sdk.Spec) ..."
|
||||
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||
Write-Info " Installing $($sdk.Spec) via uv pip..."
|
||||
& $UvCmd pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success " Installed $($sdk.Import)"
|
||||
} else {
|
||||
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
|
||||
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $UvCmd pip install `"$($sdk.Spec)`""
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -2521,11 +2488,9 @@ function Invoke-SetupWizard {
|
||||
Push-Location $InstallDir
|
||||
|
||||
# Run hermes setup using the venv Python directly (no activation needed)
|
||||
if (-not $NoVenv) {
|
||||
& ".\venv\Scripts\python.exe" -m hermes_cli.main setup
|
||||
} else {
|
||||
python -m hermes_cli.main setup
|
||||
}
|
||||
|
||||
|
||||
& ".\venv\Scripts\python.exe" -m hermes_cli.main setup
|
||||
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
+7
-36
@@ -9,7 +9,7 @@
|
||||
# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
|
||||
#
|
||||
# Or with options:
|
||||
# curl -fsSL ... | bash -s -- --no-venv --skip-setup
|
||||
# curl -fsSL ... | bash -s -- --skip-setup
|
||||
#
|
||||
# ============================================================================
|
||||
|
||||
@@ -67,7 +67,6 @@ ROOT_FHS_LAYOUT=false
|
||||
DETECTED_BROWSER_EXECUTABLE=""
|
||||
|
||||
# Options
|
||||
USE_VENV=true
|
||||
RUN_SETUP=true
|
||||
SKIP_BROWSER=false
|
||||
NO_SKILLS=false
|
||||
@@ -93,10 +92,6 @@ fi
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--no-venv)
|
||||
USE_VENV=false
|
||||
shift
|
||||
;;
|
||||
--skip-setup)
|
||||
RUN_SETUP=false
|
||||
shift
|
||||
@@ -160,7 +155,6 @@ while [[ $# -gt 0 ]]; do
|
||||
echo "Usage: install.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --no-venv Don't create virtual environment"
|
||||
echo " --skip-setup Skip interactive setup wizard"
|
||||
echo " --skip-browser Skip Playwright/Chromium install (browser tools won't work)"
|
||||
echo " --no-skills Start with a blank slate — seed no bundled skills, and"
|
||||
@@ -1197,11 +1191,6 @@ clone_repo() {
|
||||
}
|
||||
|
||||
setup_venv() {
|
||||
if [ "$USE_VENV" = false ]; then
|
||||
log_info "Skipping virtual environment (--no-venv)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
log_info "Creating virtual environment with Termux Python..."
|
||||
|
||||
@@ -1253,12 +1242,8 @@ install_deps() {
|
||||
fi
|
||||
|
||||
if [ "$DISTRO" = "termux" ]; then
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
else
|
||||
PIP_PYTHON="$PYTHON_PATH"
|
||||
fi
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
PIP_PYTHON="$INSTALL_DIR/venv/bin/python"
|
||||
|
||||
if [ -z "${ANDROID_API_LEVEL:-}" ]; then
|
||||
ANDROID_API_LEVEL="$(getprop ro.build.version.sdk 2>/dev/null || true)"
|
||||
@@ -1307,10 +1292,8 @@ install_deps() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
# Tell uv to install into our venv (no need to activate)
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
fi
|
||||
# Tell uv to install into our venv (no need to activate)
|
||||
export VIRTUAL_ENV="$INSTALL_DIR/venv"
|
||||
|
||||
# On Debian/Ubuntu (including WSL), some Python packages need build tools.
|
||||
# Check and offer to install them if missing.
|
||||
@@ -1496,15 +1479,7 @@ PY
|
||||
setup_path() {
|
||||
log_info "Setting up hermes command..."
|
||||
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
HERMES_BIN="$INSTALL_DIR/venv/bin/hermes"
|
||||
else
|
||||
HERMES_BIN="$(which hermes 2>/dev/null || echo "")"
|
||||
if [ -z "$HERMES_BIN" ]; then
|
||||
log_warn "hermes not found on PATH after install"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
HERMES_BIN="$INSTALL_DIR/venv/bin/hermes"
|
||||
|
||||
# Verify the entry point script was actually generated
|
||||
if [ ! -x "$HERMES_BIN" ]; then
|
||||
@@ -1968,11 +1943,7 @@ run_setup_wizard() {
|
||||
|
||||
# Run hermes setup using the venv Python directly (no activation needed).
|
||||
# Redirect stdin from /dev/tty so interactive prompts work when piped from curl.
|
||||
if [ "$USE_VENV" = true ]; then
|
||||
"$INSTALL_DIR/venv/bin/python" -m hermes_cli.main setup < /dev/tty
|
||||
else
|
||||
python -m hermes_cli.main setup < /dev/tty
|
||||
fi
|
||||
"$INSTALL_DIR/venv/bin/python" -m hermes_cli.main setup < /dev/tty
|
||||
}
|
||||
|
||||
maybe_start_gateway() {
|
||||
|
||||
@@ -42,21 +42,15 @@ from hermes_cli.psutil_android import (
|
||||
PsutilAndroidInstallError,
|
||||
prepare_patched_psutil_sdist,
|
||||
)
|
||||
|
||||
from hermes_cli.managed_uv import get_pip_cmd
|
||||
|
||||
|
||||
def _resolve_install_cmd(pip_arg: str | None, prefer_uv: bool) -> list[str]:
|
||||
if pip_arg:
|
||||
return pip_arg.split()
|
||||
if prefer_uv:
|
||||
uv = shutil.which("uv")
|
||||
if not uv:
|
||||
sys.exit("--uv requested but no uv on PATH")
|
||||
return [uv, "pip"]
|
||||
auto_uv = shutil.which("uv")
|
||||
if auto_uv:
|
||||
return [auto_uv, "pip"]
|
||||
return [sys.executable, "-m", "pip"]
|
||||
|
||||
# Hermes guarantees a managed uv binary; use the centralized helper.
|
||||
return get_pip_cmd()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
@@ -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"
|
||||
|
||||
+2
-1
@@ -30,7 +30,8 @@ from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
|
||||
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
|
||||
|
||||
|
||||
@@ -739,7 +739,8 @@ def main() -> int:
|
||||
print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_root = get_hermes_source_root()
|
||||
|
||||
# Resolve discovery roots: positional path args override --paths if any
|
||||
# were supplied, otherwise --paths (which itself defaults to 'tests').
|
||||
|
||||
@@ -360,7 +360,8 @@ def main(
|
||||
print(f" Seed: {seed}")
|
||||
|
||||
# Setup paths
|
||||
base_dir = Path(__file__).parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
base_dir = get_hermes_source_root()
|
||||
sampled_dir = base_dir / "data" / f"{output_name}_raw"
|
||||
compressed_dir = base_dir / "data" / f"{output_name}_batches"
|
||||
final_output = base_dir / "data" / f"{output_name}.jsonl"
|
||||
|
||||
@@ -29,6 +29,15 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Allow importing from the repo root when run as a script
|
||||
import sys
|
||||
from pathlib import Path
|
||||
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 pip_install
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure sibling modules (_hermes_home) are importable when run standalone.
|
||||
@@ -107,10 +116,9 @@ def install_deps():
|
||||
|
||||
# First choice: pip in the current interpreter. Works for most installs.
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "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:
|
||||
@@ -141,8 +149,8 @@ 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(f"Or manually: {sys.executable} -m pip install {' '.join(REQUIRED_PACKAGES)}")
|
||||
print(" uv pip install -e '.[google]' # from the hermes-agent checkout")
|
||||
print(f"Or manually: uv pip install {' '.join(REQUIRED_PACKAGES)}")
|
||||
return False
|
||||
|
||||
|
||||
|
||||
@@ -203,7 +203,8 @@ class TestGatewayBridgeCodeParity:
|
||||
in source. Assert the dynamic shape and the canonical built-in keys
|
||||
bridged set instead.
|
||||
"""
|
||||
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
gateway_path = get_hermes_source_root() / "gateway" / "run.py"
|
||||
# Pin encoding to UTF-8: source files in this repo are UTF-8, but
|
||||
# Path.read_text() defaults to the system locale — which is cp1252
|
||||
# on most Western Windows installs and crashes as soon as the file
|
||||
@@ -224,7 +225,8 @@ class TestGatewayBridgeCodeParity:
|
||||
|
||||
def test_gateway_no_compression_env_bridge(self):
|
||||
"""Gateway should NOT bridge compression config to env vars (config-only)."""
|
||||
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
gateway_path = get_hermes_source_root() / "gateway" / "run.py"
|
||||
# See note in test_gateway_has_auxiliary_bridge — pin UTF-8 so the
|
||||
# test runs on Windows where the default locale is cp1252.
|
||||
content = gateway_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -258,7 +258,8 @@ class TestPackaging:
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
content = (get_hermes_source_root() / "pyproject.toml").read_text()
|
||||
return tomllib.loads(content)["project"]["optional-dependencies"]
|
||||
|
||||
def test_bedrock_extra_exists(self):
|
||||
|
||||
@@ -14,7 +14,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
PROJECT_ROOT = get_hermes_source_root()
|
||||
PLATFORMS_DIR = PROJECT_ROOT / "plugins" / "platforms"
|
||||
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ class TestNoMoreBareDeleteSites:
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
gateway_run = (Path(__file__).parent.parent.parent / "gateway" / "run.py").read_text()
|
||||
from hermes_constants import get_hermes_source_root
|
||||
gateway_run = (get_hermes_source_root() / "gateway" / "run.py").read_text()
|
||||
# Match `del self._running_agents[...]` that is NOT inside a
|
||||
# triple-quoted docstring. We scan non-docstring lines only.
|
||||
lines = gateway_run.splitlines()
|
||||
|
||||
@@ -71,42 +71,41 @@ def _patch_managed_uv(request):
|
||||
|
||||
|
||||
class TestCmdUpdatePip:
|
||||
"""Regression tests for pip-install update flows."""
|
||||
"""Tests for pip-install update flows."""
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/uv")
|
||||
@patch("subprocess.run")
|
||||
def test_update_pip_exports_virtualenv_from_sys_prefix(
|
||||
def test_update_pip_uv_tool_upgrade(
|
||||
self, mock_run, _mock_which, mock_args, monkeypatch
|
||||
):
|
||||
"""uv-tool installs still use uv tool upgrade."""
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
hm._cmd_update_pip(mock_args)
|
||||
with patch("hermes_cli.config.is_uv_tool_install", return_value=True):
|
||||
hm._cmd_update_pip(mock_args)
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
|
||||
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "tool", "upgrade", "hermes-agent"]
|
||||
|
||||
@patch("shutil.which", return_value="/usr/bin/uv")
|
||||
@patch("subprocess.run")
|
||||
def test_update_pip_does_not_export_virtualenv_for_system_python(
|
||||
self, mock_run, _mock_which, mock_args, monkeypatch
|
||||
def test_update_pip_plain_pypi_install_exits_with_error(
|
||||
self, _mock_which, mock_args, monkeypatch, capsys
|
||||
):
|
||||
"""Plain pip install (not uv-tool, not pipx) now exits with a helpful error."""
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/usr")
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/tmp/some-venv")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
hm._cmd_update_pip(mock_args)
|
||||
with patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
hm._cmd_update_pip(mock_args)
|
||||
|
||||
assert mock_run.call_count == 1
|
||||
assert "env" not in mock_run.call_args.kwargs
|
||||
assert exc_info.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "no longer supported" in out
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in out
|
||||
|
||||
|
||||
class TestCmdUpdateBranchFallback:
|
||||
|
||||
@@ -24,7 +24,8 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
|
||||
# Path to the source files under test
|
||||
_SRC_ROOT = Path(__file__).parent.parent.parent / "hermes_cli"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
_SRC_ROOT = get_hermes_source_root() / "hermes_cli"
|
||||
|
||||
|
||||
class TestInitPairClampingBehavior:
|
||||
|
||||
@@ -227,16 +227,19 @@ def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path,
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, ""))
|
||||
|
||||
issues = []
|
||||
doctor._check_gateway_service_linger(issues)
|
||||
from hermes_cli.doctor._registry import DiagnosticReport
|
||||
from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger
|
||||
|
||||
report = DiagnosticReport()
|
||||
report.section("Gateway Service")
|
||||
check_gateway_service_linger(report)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "Gateway Service" in out
|
||||
assert "Systemd linger disabled" in out
|
||||
assert "loginctl enable-linger" in out
|
||||
assert issues == [
|
||||
"Enable linger for the gateway user service: sudo loginctl enable-linger $USER"
|
||||
]
|
||||
issues = [i.text for i in report._issues]
|
||||
assert any("linger" in i.lower() for i in issues)
|
||||
|
||||
|
||||
def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys):
|
||||
@@ -245,12 +248,16 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
|
||||
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
|
||||
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
|
||||
|
||||
issues = []
|
||||
doctor._check_gateway_service_linger(issues)
|
||||
from hermes_cli.doctor._registry import DiagnosticReport
|
||||
from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger
|
||||
|
||||
report = DiagnosticReport()
|
||||
report.section("Gateway Service")
|
||||
check_gateway_service_linger(report)
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert out == ""
|
||||
assert issues == []
|
||||
assert report._issues == []
|
||||
|
||||
|
||||
# ── Memory provider section (doctor should only check the *active* provider) ──
|
||||
@@ -484,7 +491,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
|
||||
|
||||
out = buf.getvalue()
|
||||
assert "model.provider 'openrouter' is set but no API key is configured" in out
|
||||
assert "No credentials found for provider 'openrouter'." in out
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -240,7 +240,7 @@ def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch):
|
||||
# Stub every destructive external so the test only exercises the control
|
||||
# flow + the real GUI sweep (which is safe inside tmp_path).
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
@@ -274,7 +274,7 @@ def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch):
|
||||
fake_code.mkdir()
|
||||
|
||||
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
|
||||
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code)
|
||||
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
|
||||
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
|
||||
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
|
||||
|
||||
@@ -125,10 +125,8 @@ def test_kanban_swarm_uses_existing_humanizer_skill():
|
||||
which actually exists at skills/creative/humanizer/SKILL.md."""
|
||||
import pathlib
|
||||
|
||||
swarm_path = (
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent
|
||||
/ "hermes_cli" / "kanban_swarm.py"
|
||||
)
|
||||
from hermes_constants import get_hermes_source_root
|
||||
swarm_path = get_hermes_source_root() / "hermes_cli" / "kanban_swarm.py"
|
||||
src = swarm_path.read_text()
|
||||
assert "avoid-ai-writing" not in src, (
|
||||
"kanban_swarm.py must not reference 'avoid-ai-writing' — that "
|
||||
@@ -140,9 +138,7 @@ def test_kanban_swarm_uses_existing_humanizer_skill():
|
||||
)
|
||||
|
||||
# And the replacement skill must actually exist on disk.
|
||||
skills_root = (
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent / "skills"
|
||||
)
|
||||
skills_root = get_hermes_source_root() / "skills"
|
||||
humanizer_path = skills_root / "creative" / "humanizer" / "SKILL.md"
|
||||
assert humanizer_path.is_file(), (
|
||||
f"humanizer skill missing at {humanizer_path}; the kanban_swarm fix "
|
||||
|
||||
@@ -31,12 +31,10 @@ def test_managed_install_takes_precedence(tmp_path):
|
||||
|
||||
|
||||
def test_recommended_update_command_pip():
|
||||
"""Pip installs recommend pip install --upgrade."""
|
||||
"""Pip installs now redirect to the official installer."""
|
||||
from hermes_cli.config import recommended_update_command_for_method
|
||||
cmd = recommended_update_command_for_method("pip")
|
||||
assert "pip install" in cmd or "uv pip install" in cmd
|
||||
assert "--upgrade" in cmd
|
||||
assert "hermes-agent" in cmd
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in cmd
|
||||
|
||||
|
||||
def test_stamp_file_takes_precedence(tmp_path):
|
||||
@@ -103,8 +101,8 @@ def test_banner_warns_on_pip_install(tmp_path):
|
||||
)
|
||||
out = buf.getvalue()
|
||||
|
||||
assert "officially" in out
|
||||
assert "instability" in out
|
||||
assert "no longer supported" in out
|
||||
assert "install.sh" in out
|
||||
|
||||
|
||||
def test_banner_no_pip_warning_on_git_install(tmp_path):
|
||||
|
||||
@@ -210,9 +210,8 @@ def test_real_handler_uses_os_exit_for_kanban_workers():
|
||||
"""
|
||||
import pathlib
|
||||
|
||||
cli_path = (
|
||||
pathlib.Path(__file__).resolve().parent.parent.parent / "cli.py"
|
||||
)
|
||||
from hermes_constants import get_hermes_source_root
|
||||
cli_path = get_hermes_source_root() / "cli.py"
|
||||
src = cli_path.read_text()
|
||||
# Locate the handler body.
|
||||
start = src.find("def _signal_handler_q(signum, frame):")
|
||||
|
||||
@@ -70,20 +70,21 @@ def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
|
||||
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
|
||||
m._write_update_incomplete_marker()
|
||||
|
||||
seen = {"ensurepip": False, "install": False}
|
||||
seen = {"uv_used": False, "install": False}
|
||||
|
||||
def fake_ensure_uv():
|
||||
return "/fake/uv"
|
||||
|
||||
def fake_run(cmd, *a, **k):
|
||||
if "ensurepip" in cmd:
|
||||
seen["ensurepip"] = True
|
||||
|
||||
if "uv" in cmd:
|
||||
seen["uv_used"] = True
|
||||
class R:
|
||||
returncode = 0
|
||||
|
||||
return R()
|
||||
|
||||
monkeypatch.setattr(m.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
|
||||
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", fake_ensure_uv)
|
||||
monkeypatch.setattr(
|
||||
m,
|
||||
"_install_python_dependencies_with_optional_fallback",
|
||||
@@ -92,7 +93,6 @@ def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
|
||||
|
||||
m._recover_from_interrupted_install()
|
||||
|
||||
assert seen["ensurepip"] is True, "ensurepip must run unconditionally first"
|
||||
assert seen["install"] is True, "dep install must run"
|
||||
assert not m._update_marker_path().exists(), "marker cleared on success"
|
||||
|
||||
|
||||
@@ -140,28 +140,25 @@ class TestRecommendedUpdateCommandForUvTool:
|
||||
cmd = config.recommended_update_command_for_method("pip")
|
||||
assert cmd == "uv tool upgrade hermes-agent"
|
||||
|
||||
def test_uv_pip_install_keeps_legacy_recommendation(self):
|
||||
"""Existing behavior: uv is on PATH but Hermes is a regular pip install."""
|
||||
from hermes_cli import config
|
||||
|
||||
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
||||
patch.object(config, "is_uv_tool_install", return_value=False):
|
||||
cmd = config.recommended_update_command_for_method("pip")
|
||||
assert cmd == "uv pip install --upgrade hermes-agent"
|
||||
|
||||
def test_no_uv_falls_back_to_plain_pip(self):
|
||||
def test_uv_pip_install_redirects_to_installer(self):
|
||||
"""Non-uv-tool, non-pipx pip install → redirect to official installer."""
|
||||
from hermes_cli import config
|
||||
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch.object(config, "is_uv_tool_install", return_value=False):
|
||||
cmd = config.recommended_update_command_for_method("pip")
|
||||
assert cmd == "pip install --upgrade hermes-agent"
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in cmd
|
||||
|
||||
def test_no_uv_also_redirects_to_installer(self):
|
||||
from hermes_cli import config
|
||||
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch.object(config, "is_uv_tool_install", return_value=False):
|
||||
cmd = config.recommended_update_command_for_method("pip")
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in cmd
|
||||
|
||||
def test_recommendation_does_not_spawn_subprocess(self):
|
||||
"""Computing the recommendation string must be cheap — no ``uv tool list``
|
||||
spawn. Copilot review on PR #29703 flagged the prior subprocess hop
|
||||
as adding overhead and a multi-second timeout window for what is
|
||||
purely a display string."""
|
||||
"""Computing the recommendation string must be cheap — no subprocess spawn."""
|
||||
from hermes_cli import config
|
||||
|
||||
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
|
||||
@@ -170,7 +167,7 @@ class TestRecommendedUpdateCommandForUvTool:
|
||||
patch("subprocess.run") as mock_run:
|
||||
cmd = config.recommended_update_command_for_method("pip")
|
||||
mock_run.assert_not_called()
|
||||
assert cmd == "uv pip install --upgrade hermes-agent"
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -191,35 +188,29 @@ class TestCmdUpdatePipUsesUvTool:
|
||||
|
||||
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
|
||||
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
|
||||
def test_runs_uv_pip_install_when_not_uv_tool(self, capsys):
|
||||
"""Non-uv-tool with uv present → PyPI path, now exits with error."""
|
||||
from hermes_cli.main import _cmd_update_pip
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
|
||||
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
_cmd_update_pip(SimpleNamespace())
|
||||
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"/usr/local/bin/uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"hermes-agent",
|
||||
]
|
||||
assert exc_info.value.code == 1
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_falls_back_to_pip_when_no_uv(self, mock_run):
|
||||
def test_falls_back_to_pip_when_no_uv(self, capsys):
|
||||
"""No uv, not uv-tool → PyPI path, now exits with error."""
|
||||
from hermes_cli.main import _cmd_update_pip
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
_cmd_update_pip(SimpleNamespace())
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
assert exc_info.value.code == 1
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
|
||||
@@ -255,12 +246,7 @@ class TestCmdUpdatePipUsesUvTool:
|
||||
|
||||
|
||||
class TestCmdUpdatePipInstallLayouts:
|
||||
"""The uv pip path must adapt to where the running interpreter lives:
|
||||
|
||||
- inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system``
|
||||
- bare pip outside any venv -> add ``--system``, no overlay
|
||||
- pipx-managed -> ``pipx upgrade``
|
||||
"""
|
||||
"""Update path tests for various install layouts."""
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch):
|
||||
@@ -278,65 +264,39 @@ class TestCmdUpdatePipInstallLayouts:
|
||||
hm._cmd_update_pip(SimpleNamespace())
|
||||
|
||||
assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"]
|
||||
# pipx upgrade ignores VIRTUAL_ENV; we must not set it.
|
||||
assert "env" not in mock_run.call_args.kwargs
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_pipx_layout_without_pipx_binary_treated_as_venv(
|
||||
self, mock_run, monkeypatch
|
||||
def test_pipx_layout_without_pipx_binary_exits_with_error(
|
||||
self, monkeypatch, capsys
|
||||
):
|
||||
"""pipx layout detected but no pipx binary → unsupported, show installer."""
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
# pipx layout detected via prefix, but pipx binary missing on PATH.
|
||||
def _which(name):
|
||||
return "/usr/bin/uv" if name == "uv" else None
|
||||
|
||||
with patch("shutil.which", side_effect=_which), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
||||
with patch("shutil.which", return_value=None), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
hm._cmd_update_pip(SimpleNamespace())
|
||||
|
||||
# prefix != base_prefix, so this is treated as a venv -> overlay, no --system.
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent",
|
||||
]
|
||||
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent")
|
||||
assert exc_info.value.code == 1
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch):
|
||||
def test_plain_venv_pip_install_exits_with_error(self, monkeypatch, capsys):
|
||||
"""Non-uv-tool, non-pipx regular venv → PyPI path, now unsupported."""
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
# No venv: prefix == base_prefix.
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/usr")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/uv"), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
||||
hm._cmd_update_pip(SimpleNamespace())
|
||||
|
||||
assert mock_run.call_args[0][0] == [
|
||||
"/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent",
|
||||
]
|
||||
assert "env" not in mock_run.call_args.kwargs
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch):
|
||||
from hermes_cli import main as hm
|
||||
|
||||
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
|
||||
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
||||
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv")
|
||||
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/uv"), \
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
|
||||
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
|
||||
pytest.raises(SystemExit) as exc_info:
|
||||
hm._cmd_update_pip(SimpleNamespace())
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--system" not in cmd
|
||||
assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
|
||||
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"
|
||||
assert exc_info.value.code == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "no longer supported" in out
|
||||
assert "hermes-agent.nousresearch.com/install.sh" in out
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_constants import get_hermes_source_root as _repo_root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OpenAI SDK construction preserves the callable
|
||||
@@ -245,8 +247,7 @@ class TestBatchRunnerCallableHandling:
|
||||
"""Pin the predicate string in batch_runner so refactors that
|
||||
change it are caught here. Reading the source rather than
|
||||
importing avoids spinning up the full BatchRunner."""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "batch_runner.py").read_text()
|
||||
assert "callable(self.api_key) and not isinstance(self.api_key, str)" in src, (
|
||||
"BatchRunner.api_key callable check changed — update test or "
|
||||
@@ -278,7 +279,7 @@ class TestCliEnsureRuntimeCredentialsCallable:
|
||||
# ``_ensure_runtime_credentials`` was extracted from cli.py into the
|
||||
# ``CLIAgentSetupMixin`` (god-file decomposition Phase 4). Read the
|
||||
# module the method actually lives in now.
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "hermes_cli" / "cli_agent_setup_mixin.py").read_text()
|
||||
# The fix introduces ``_is_callable_provider`` which gates the
|
||||
# string-only check so callable token providers survive.
|
||||
@@ -305,8 +306,7 @@ class TestInlinedDisplayMasks:
|
||||
client init paths must guard their banner prints with
|
||||
``is_token_provider`` so a callable Entra ID provider doesn't
|
||||
crash ``len(api_key)``."""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "agent" / "agent_init.py").read_text()
|
||||
assert src.count("is_token_provider(") >= 2, (
|
||||
"agent/agent_init.py must guard BOTH masked-banner paths "
|
||||
@@ -325,8 +325,7 @@ class TestInlinedDisplayMasks:
|
||||
callable Entra ID providers. The inlined version uses
|
||||
``is_token_provider`` and prints the same static label as the
|
||||
run_agent banners."""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "cli.py").read_text()
|
||||
assert "is_token_provider(self.api_key)" in src, (
|
||||
"cli.HermesCLI.show_config must guard self.api_key via "
|
||||
@@ -346,8 +345,7 @@ class TestInlinedDisplayMasks:
|
||||
defensively the helper must also accept a callable directly
|
||||
and return the placeholder rather than crashing on
|
||||
``len(callable)``."""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "run_agent.py").read_text()
|
||||
# The function now starts with a callable check.
|
||||
assert (
|
||||
@@ -365,8 +363,7 @@ class TestInlinedDisplayMasks:
|
||||
was extracted after this feature was first written). It used
|
||||
to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID +
|
||||
Anthropic-style mode that's a callable; slicing crashes."""
|
||||
from pathlib import Path
|
||||
src = (Path(__file__).resolve().parent.parent.parent
|
||||
src = (_repo_root()
|
||||
/ "agent" / "conversation_loop.py").read_text()
|
||||
# The Anthropic 401 block now branches on is_token_provider
|
||||
# before slicing the key.
|
||||
|
||||
@@ -21,7 +21,8 @@ import pytest
|
||||
import yaml
|
||||
|
||||
# Ensure the repo root is importable when running via `pytest tests/...`.
|
||||
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
_REPO_ROOT = get_hermes_source_root()
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CHAT_SIDEBAR = Path(__file__).resolve().parent.parent / "web/src/components/ChatSidebar.tsx"
|
||||
from hermes_constants import get_hermes_source_root
|
||||
CHAT_SIDEBAR = get_hermes_source_root() / "web/src/components/ChatSidebar.tsx"
|
||||
|
||||
|
||||
def test_sidecar_session_create_requests_close_on_disconnect():
|
||||
|
||||
@@ -26,7 +26,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron"
|
||||
MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist"
|
||||
INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist"
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
|
||||
MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh"
|
||||
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
|
||||
|
||||
@@ -12,7 +12,8 @@ from pathlib import Path
|
||||
|
||||
|
||||
def _dockerfile_text() -> str:
|
||||
return (Path(__file__).parent.parent / "Dockerfile").read_text(encoding="utf-8")
|
||||
from hermes_constants import get_hermes_source_root
|
||||
return (get_hermes_source_root() / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_tini_compat_symlink_present():
|
||||
|
||||
@@ -3,7 +3,8 @@ from pathlib import Path
|
||||
import importlib.util
|
||||
|
||||
# Load the hyphenated script name dynamically
|
||||
repo_root = Path(__file__).parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
repo_root = get_hermes_source_root()
|
||||
script_path = repo_root / "optional-skills" / "security" / "oss-forensics" / "scripts" / "evidence-store.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("evidence_store", str(script_path))
|
||||
|
||||
@@ -21,7 +21,8 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ unsupported distribution.
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
from hermes_constants import get_hermes_source_root
|
||||
REPO_ROOT = get_hermes_source_root()
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user