Compare commits

...
Author SHA1 Message Date
ethernet 82d3d44020 ci: make some *ty* stuff errors 2026-06-12 15:04:24 -04:00
ethernet f5f41a0921 feat: refactor doctor, unify pip install and project root 2026-06-12 15:04:24 -04:00
ethernet af1780f2ca feat: remove no-venv support
- Remove --no-venv/-NoVenv flags from install.sh and install.ps1. Venv
creation is now mandatory.
- Update installation docs to explicitly state that a virtual
environment is strictly required and global/system Python installations
are not supported.
2026-06-11 22:48:39 -04:00
ethernet 74fbd7f01f fix: update all error messages to recommend 'uv pip install' instead of raw pip
- Replace sys.executable -m pip install with 'uv pip install' in error messages across:
  - gateway/run.py (PyNaCl missing)
  - tools/voice_mode.py (sounddevice/numpy missing)
  - cli.py (voice mode dependencies missing)
  - mcp_serve.py (mcp package missing)
  - hermes_cli/web_server.py (fastapi/uvicorn missing)
- Ensures 100% consistency: we NEVER recommend raw pip to the user anywhere in the codebase, even in error messages.
2026-06-11 20:48:49 -04:00
ethernet afa3e89119 feat: add atomic venv recreation and doctor integration
- Add recreate_venv_atomically() to managed_uv.py: builds fresh venv.new, installs deps, and atomically swaps venv -> venv.bak and venv.new -> venv. This guarantees safe migration from legacy pip venvs without stripping dependencies.
- Update get_pip_cmd() circuit breaker to suggest running hermes doctor.
- Add 'Virtual Environment Integrity' check to hermes doctor.
- hermes doctor --fix now automatically detects legacy/broken venvs and atomically recreates them using uv.
2026-06-11 19:46:26 -04:00
ethernet c542884168 fix: update all manual recovery and diagnostic messages to recommend uv
- Update interrupted install recovery message to recommend 'uv pip install' or re-running the installer
- Update Web UI dependency missing message to prioritize 'uv pip install'
- Update hermes doctor missing optional dependency messages to recommend 'uv pip install'
- Ensures 100% consistency: we NEVER recommend raw pip to the user anywhere in the codebase
2026-06-11 19:19:43 -04:00
ethernet 5f298e5b2a fix: update hermes doctor to reflect strict uv requirement
- Check for system uv (e.g., Termux pkg install uv) as a secondary fallback in doctor
- Replace outdated 'will fall back to plain pip' warning with a clear check_fail
- Ensures hermes doctor accurately reflects the new strict uv invariant
2026-06-11 19:19:43 -04:00
ethernet 4deaa42ccb refactor: strictly enforce uv requirement, eliminate ALL raw pip fallbacks
- Remove the degenerate  fallback from  entirely.
- If neither managed uv nor system PATH uv is found, raise a clear .
- This enforces the architectural invariant: Hermes strictly requires uv for dependency management.
- Silently falling back to raw pip only masks environment corruption and re-introduces the ensurepip/PEP-668 bugs this refactor was built to eliminate.
- Termux users are correctly guided to use the canonical  if the managed installer fails.
2026-06-11 19:19:43 -04:00
ethernet 4bdb2ba38c fix: add PATH uv fallback to get_pip_cmd() for Termux compatibility
- The official uv installer may fail on Termux due to glibc vs bionic differences.
- Hermes already has _ensure_uv_for_termux() which falls back to 'pip install uv'.
- Update get_pip_cmd() to check shutil.which('uv') as a secondary fallback before resorting to raw pip, ensuring Termux users who successfully install uv via pip actually get to use it!
2026-06-11 19:19:43 -04:00
ethernet 71bd99b8b0 refactor: unify all venv and pip installation logic into managed_uv.py
- Add get_venv_root() and pip_install() to hermes_cli/managed_uv.py
- pip_install() now handles VIRTUAL_ENV, PATH prepending, PYTHONPATH/PYTHONHOME cleanup, and the get_pip_cmd() fallback in ONE place
- Update tools/lazy_deps.py to use the unified pip_install()
- Update hermes_cli/tools_config.py to use pip_install() and remove redundant fallback logic
- Update hermes_cli/main.py to use get_pip_cmd() and remove all manual VIRTUAL_ENV/PYTHONPATH manipulation
- All dependency installation now flows through a single, authoritative, bulletproof helper!
2026-06-11 19:19:43 -04:00
ethernet 04c8fcd1df refactor: update google-workspace setup script to use get_pip_cmd() 2026-06-11 19:19:43 -04:00
ethernet e77f1de940 refactor: exhaustively replace manual pip invocations with get_pip_cmd()
- Update hermes_cli/setup.py to use get_pip_cmd()
- Update hermes_cli/dingtalk_auth.py to use get_pip_cmd()
- Update agent/lsp/install.py to use get_pip_cmd()
- Update hermes_cli/main.py update fallback to use get_pip_cmd()
- tools/env_probe.py intentionally left alone as it probes system pip for diagnostics
2026-06-11 19:19:43 -04:00
ethernet c64972af77 refactor: replace manual pip invocations with centralized get_pip_cmd()
- Update scripts/install_psutil_android.py to use get_pip_cmd()
- Update plugins/platforms/google_chat/oauth.py to use get_pip_cmd()
- Update plugins/memory/honcho/cli.py to use get_pip_cmd()
- Update plugins/google_meet/cli.py to use get_pip_cmd()
- Update hermes_cli/memory_setup.py to use get_pip_cmd() and remove messy manual uv/pip fallback logic
2026-06-11 19:19:43 -04:00
ethernet 657bd1d328 refactor: centralize get_pip_cmd() in managed_uv and add doctor check
- Add global get_pip_cmd() to hermes_cli/managed_uv.py
- Remove duplicate _get_pip_cmd() from tools/lazy_deps.py and hermes_cli/tools_config.py
- Update both files to import and use the centralized get_pip_cmd()
- Add 'Dependency Management' section to hermes doctor to verify managed uv availability
2026-06-11 19:19:43 -04:00
ethernet 8e291759fc fix(doctor): clarify that python 3.10 is the minimum supported version 2026-06-11 19:19:43 -04:00
ethernet 02df5207a9 fix(doctor): note that git is required for update 2026-06-11 19:19:43 -04:00
ethernet 23ce5c00ad docs/cli: apply deprecations and platform warnings from support tiers plan
- Step 7: Add deprecate! to Homebrew formula and mark README as frozen/discontinued
- Step 10: Add best-effort support banner to Termux / Android docs
- Step 11: Add explicit unsupported warning for macOS x86_64 (Intel) in `hermes doctor`
2026-06-11 19:19:43 -04:00
ethernet 8ee19f354d refactor: rip out ensurepip and standardize entirely on managed uv
- Add strict rule to AGENTS.md: always use ensure_uv() / resolve_uv() for dependency installation
- Create _get_pip_cmd() helpers returning ["<managed_uv>", "pip"] with degenerate fallback
- Update hermes_cli/tools_config.py to use resolve_uv() instead of shutil.which("uv")
- Update hermes_cli/main.py install and recovery paths to use ensure_uv()
- Update tools/lazy_deps.py to use managed uv path directly without hermes_cli dependency
- Update tools/environments/modal.py to remove ensurepip from dockerfile setup
- Update scripts/install.ps1 to use $UvCmd for SDK installation instead of ensurepip
- Update tests to reflect removal of ensurepip bootstrapping
2026-06-11 19:19:43 -04:00
ethernet f62abc9ac2 docs: add pip deprecation and migration guides to installation and updating docs
- Add prominent platform support callout to installation.md
- Remove 'pip install' row from installation layout table
- Add 'Migrating from pip / PyPI' subsection to installation.md
- Replace 'pip installs' sections in updating.md with clear deprecation notices and migration links
2026-06-11 16:16:31 -04:00
ethernet 967574f9d7 docs: restructure platform support to prioritize CONTRIBUTING.md
- Add full 'Platform Support' section to CONTRIBUTING.md with detailed tier breakdown
- Update AGENTS.md to provide a concise summary for agents and link to CONTRIBUTING.md
- Update 'Contribution Priorities' in CONTRIBUTING.md to reference the new platform support section
2026-06-11 16:15:18 -04:00
ethernet 3017095449 docs: add platform support tiers to AGENTS.md and create reference doc
- Add 'Platform Support' section to AGENTS.md outlining the 3 support tiers (Explicitly supported, Best-effort, Explicitly unsupported)
- Create canonical user-facing reference at website/docs/reference/platform-support.md
- Include migration guides for deprecated pip/PyPI and Homebrew installations
- Clarify Nix and Termux best-effort boundaries
2026-06-11 15:44:48 -04:00
ethernet 89be1f52b2 plan 2026-06-11 15:39:45 -04:00
ethernet bfd6d165a7 platform support tiers 2026-06-11 13:36:04 -04:00
128 changed files with 3867 additions and 3038 deletions
+16
View File
@@ -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
View File
@@ -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**.
+2 -1
View File
@@ -234,7 +234,8 @@ def main(argv: list[str] | None = None) -> None:
logger.info("Starting hermes-agent ACP adapter")
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
project_root = str(Path(__file__).resolve().parent.parent)
from hermes_constants import get_hermes_source_root
project_root = str(get_hermes_source_root())
if project_root not in sys.path:
sys.path.insert(0, project_root)
+2 -1
View File
@@ -115,7 +115,8 @@ def _locales_dir() -> Path:
)
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
from hermes_constants import get_hermes_source_root
source_dir = get_hermes_source_root() / "locales"
if source_dir.is_dir():
return source_dir
+4 -1
View File
@@ -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,
+2 -2
View File
@@ -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:
+1
View File
@@ -36,6 +36,7 @@ from typing import List, Optional
# Add parent directory to path for imports BEFORE repo-level imports.
# Without this, standalone invocations (e.g. after `hermes update` reloads
# the module) fail with ModuleNotFoundError for hermes_time et al.
# Bootstrap sys.path before imports — cannot use get_hermes_source_root() here yet.
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_home
+2 -1
View File
@@ -213,7 +213,8 @@ class Platform(Enum):
"""Return names of bundled platform plugins under ``plugins/platforms/``."""
names: set = set()
try:
platforms_dir = Path(__file__).parent.parent / "plugins" / "platforms"
from hermes_constants import get_hermes_source_root
platforms_dir = get_hermes_source_root() / "plugins" / "platforms"
if platforms_dir.is_dir():
for child in platforms_dir.iterdir():
if (
+6 -4
View File
@@ -895,7 +895,8 @@ os.environ["_HERMES_GATEWAY"] = "1"
_ensure_ssl_certs()
# Add parent directory to path
sys.path.insert(0, str(Path(__file__).parent.parent))
from hermes_constants import get_hermes_source_root
sys.path.insert(0, str(get_hermes_source_root()))
# Resolve Hermes home directory (respects HERMES_HOME override)
from hermes_constants import get_hermes_home
@@ -1551,7 +1552,8 @@ def _check_unavailable_skill(command_name: str) -> str | None:
# Check optional skills (shipped with repo but not installed)
from hermes_constants import get_optional_skills_dir
repo_root = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
repo_root = get_hermes_source_root()
optional_dir = get_optional_skills_dir(repo_root / "optional-skills")
if optional_dir.exists():
for skill_md in optional_dir.rglob("SKILL.md"):
@@ -6085,7 +6087,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
elif platform == Platform.SLACK:
from gateway.platforms.slack import SlackAdapter, check_slack_requirements
if not check_slack_requirements():
logger.warning("Slack: slack-bolt not installed. Run: pip install 'hermes-agent[slack]'")
logger.warning("Slack: slack-bolt not installed. Run: uv pip install -e '.[slack]' (from the hermes-agent checkout)")
return None
return SlackAdapter(config)
@@ -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}"
+2 -1
View File
@@ -3431,7 +3431,8 @@ class GatewaySlashCommandsMixin:
if is_managed():
return f"{format_managed_message('update Hermes Agent')}"
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
git_dir = project_root / '.git'
if not git_dir.exists():
+9 -9
View File
@@ -268,7 +268,8 @@ def check_for_updates() -> Optional[int]:
# Prefer the running code's location over the profile-scoped path.
# $HERMES_HOME/hermes-agent/ may be a stale copy from --clone-all;
# Path(__file__) always resolves to the actual installed checkout.
repo_dir = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
repo_dir = get_hermes_source_root()
if not (repo_dir / ".git").exists():
repo_dir = hermes_home / "hermes-agent"
if not (repo_dir / ".git").exists():
@@ -293,7 +294,8 @@ def _resolve_repo_dir() -> Optional[Path]:
because ``$HERMES_HOME/hermes-agent/`` may be a stale copy carried
over by ``--clone-all``.
"""
repo_dir = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
repo_dir = get_hermes_source_root()
if not (repo_dir / ".git").exists():
hermes_home = get_hermes_home()
repo_dir = hermes_home / "hermes-agent"
@@ -728,17 +730,15 @@ def build_welcome_banner(console: "Console", model: str, cwd: str,
except Exception:
pass # Never break the banner over an update check
# Pip-install warning — `pip install hermes-agent` is not the supported
# install path (it exists on PyPI for internal/CI reasons, not end users).
# Such installs miss the git checkout + installer-managed deps, so updates,
# self-update, and issue triage don't behave correctly. Warn, don't block.
# PyPI install warning — `pip install hermes-agent` is not a supported
# install path. Direct users to the official installer.
try:
from hermes_cli.config import detect_install_method
if detect_install_method() == "pip":
right_lines.append(
"[bold yellow]⚠ pip install not officially supported[/]"
"[dim yellow] — exists for reasons other than user install; "
"expect instability and an inability to support issues[/]"
"[bold yellow]⚠ the hermes-agent python package is no longer supported[/]"
"[dim yellow] please reinstall via our official installer "
"curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash[/]"
)
except Exception:
pass # Never break the banner over the install-method check
+2 -1
View File
@@ -27,10 +27,11 @@ from __future__ import annotations
from pathlib import Path
from typing import Optional
from hermes_constants import get_hermes_source_root
# Path is resolved relative to this module so it works regardless of cwd —
# matches the pattern used by ``banner._resolve_repo_dir``.
_BUILD_SHA_FILE = Path(__file__).parent.parent / ".hermes_build_sha"
_BUILD_SHA_FILE = get_hermes_source_root() / ".hermes_build_sha"
def get_build_sha(short: int = 8) -> Optional[str]:
+2 -1
View File
@@ -32,7 +32,8 @@ from hermes_cli.setup import (
logger = logging.getLogger(__name__)
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
_OPENCLAW_SCRIPT = (
get_optional_skills_dir(PROJECT_ROOT / "optional-skills")
+9 -8
View File
@@ -379,7 +379,8 @@ def detect_install_method(project_root: Optional[Path] = None) -> str:
if managed:
return managed.lower().replace(" ", "-")
if project_root is None:
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
if (project_root / ".git").is_dir():
return "git"
return "pip"
@@ -435,9 +436,10 @@ def recommended_update_command_for_method(method: str) -> str:
if is_uv_tool_install():
return "uv tool upgrade hermes-agent"
import shutil
if shutil.which("uv"):
return "uv pip install --upgrade hermes-agent"
return "pip install --upgrade hermes-agent"
if shutil.which("pipx") and "pipx" in __import__("sys").prefix.split(__import__("os").sep):
return "pipx upgrade hermes-agent"
# PyPI-based installs are no longer supported — direct to the installer.
return "curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash"
return "hermes update"
@@ -600,9 +602,6 @@ def get_env_path() -> Path:
"""Get the .env file path (for API keys)."""
return get_hermes_home() / ".env"
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def _resolve_hermes_uid_gid() -> tuple[Optional[int], Optional[int]]:
"""Read the HERMES_UID / HERMES_GID env vars set by Docker deployments.
@@ -5931,6 +5930,8 @@ def redact_key(key: str) -> str:
def show_config():
"""Display current configuration."""
from hermes_constants import get_hermes_source_root
config = load_config()
print()
@@ -5943,7 +5944,7 @@ def show_config():
print(color("◆ Paths", Colors.CYAN, Colors.BOLD))
print(f" Config: {get_config_path()}")
print(f" Secrets: {get_env_path()}")
print(f" Install: {get_project_root()}")
print(f" Install: {get_hermes_source_root()}")
# API Keys
print()
+2 -1
View File
@@ -11,7 +11,8 @@ import sys
from pathlib import Path
from typing import Iterable, List, Optional
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
sys.path.insert(0, str(PROJECT_ROOT))
from hermes_cli.colors import Colors, color
+6 -8
View File
@@ -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
-2243
View File
File diff suppressed because it is too large Load Diff
+165
View File
@@ -0,0 +1,165 @@
"""hermes_cli.doctor — diagnostic checks for Hermes Agent setup.
This module is the public face of the doctor package. It exposes the same
names the old flat doctor.py did so all existing imports and monkeypatches
in tests keep working without change:
from hermes_cli.doctor import run_doctor, HERMES_HOME, PROJECT_ROOT, _DHH
from hermes_cli.doctor import _has_provider_env_config, _PROVIDER_ENV_HINTS
from hermes_cli.doctor import _apply_doctor_tool_availability_overrides
from hermes_cli.doctor import _honcho_is_configured_for_doctor
from hermes_cli.doctor import _doctor_tool_availability_detail
from hermes_cli.doctor import _has_healthy_oauth_fallback_for_apikey_provider
from hermes_cli.doctor import _build_apikey_providers_list
from hermes_cli.doctor import shutil # tests patch shutil.which via doctor_mod.shutil
import hermes_cli.doctor as doctor_mod # monkeypatching PROJECT_ROOT, HERMES_HOME etc.
Internal layout
---------------
hermes_cli/doctor/
__init__.py you are here
_output.py ANSI rendering helpers (no external deps)
_registry.py register() decorator, DiagnosticReport, run_checks()
checks/
__init__.py imports all check modules (registers side-effects)
_helpers.py shared utils (safe_which, is_termux, )
python_env.py
security.py
dep_mgmt.py
config_files.py
xai_retirement.py
auth_providers.py
directory_structure.py
gateway_service.py
command_install.py
external_tools.py
api_connectivity.py
tool_availability.py
skills_hub.py
memory_provider.py
profiles.py
"""
from __future__ import annotations
import os
import shutil # noqa: F401 — tests monkeypatch hermes_cli.doctor.shutil
import sys
from pathlib import Path
# ── Module-level globals (monkeypatched in tests) ─────────────────────────
# These are the same names the flat doctor.py exposed.
from hermes_cli.config import get_hermes_home, get_env_path
from hermes_constants import display_hermes_home, get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
HERMES_HOME = get_hermes_home()
_DHH = display_hermes_home()
# ── Lazy env bootstrap — same as old doctor.py top-level code ─────────────
_env_path = get_env_path()
try:
from hermes_cli.env_loader import load_hermes_dotenv
load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env")
except Exception:
pass
# ── Backward-compat re-exports from sub-modules ───────────────────────────
# Import these after the globals so sub-modules can `from hermes_cli.doctor import HERMES_HOME`
from hermes_cli.doctor.checks.config_files import ( # noqa: F401
_PROVIDER_ENV_HINTS,
_has_provider_env_config,
)
from hermes_cli.doctor.checks.tool_availability import ( # noqa: F401
_apply_doctor_tool_availability_overrides,
_honcho_is_configured_for_doctor,
_doctor_tool_availability_detail,
)
from hermes_cli.doctor.checks.api_connectivity import ( # noqa: F401
_has_healthy_oauth_fallback as _has_healthy_oauth_fallback_for_apikey_provider,
_build_apikey_providers_list,
_APIKEY_PROVIDERS_CACHE,
)
# Termux helpers (some tests import directly)
from hermes_cli.doctor.checks._helpers import ( # noqa: F401
is_termux as _is_termux,
python_install_cmd as _python_install_cmd,
system_package_install_cmd as _system_package_install_cmd,
)
# ── Platform check ────────────────────────────────────────────────────────
def _check_unsupported_platform() -> None:
if sys.platform == "darwin" and os.uname().machine == "x86_64":
from hermes_cli.doctor._output import color, _Ansi
print(color(
"⚠️ WARNING: macOS x86_64 (Intel) is explicitly unsupported.\n"
"We no longer accept PRs or provide fixes for this platform.\n"
"Consider migrating to a supported platform (macOS arm64 / Apple Silicon).",
_Ansi.YELLOW,
))
print()
# ── run_doctor entry point ────────────────────────────────────────────────
def run_doctor(args) -> None:
"""Run all registered diagnostic checks.
Called by ``hermes doctor`` (via ``hermes_cli/main.py``) and also
directly by tests. ``args`` is an ``argparse.Namespace`` with at least:
args.fix bool, whether to attempt auto-fixes
args.ack str | None, advisory ID to acknowledge
"""
should_fix = getattr(args, "fix", False)
ack_target = getattr(args, "ack", None)
os.environ.setdefault("HERMES_INTERACTIVE", "1")
# Fast path: `hermes doctor --ack <id>`
if ack_target:
_handle_ack(ack_target)
return
# Trigger all @register() decorators by importing the checks package
import hermes_cli.doctor.checks # noqa: F401
from hermes_cli.doctor._output import print_banner, color, _Ansi
from hermes_cli.doctor._registry import DiagnosticReport, run_checks
print_banner()
_check_unsupported_platform()
report = DiagnosticReport(should_fix=should_fix)
run_checks(report)
report.print_summary()
def _handle_ack(ack_target: str) -> None:
from hermes_cli.doctor._output import color, _Ansi
from hermes_cli.security_advisories import ADVISORIES, ack_advisory
valid_ids = {a.id for a in ADVISORIES}
if ack_target not in valid_ids:
print(color(
f"Unknown advisory ID: {ack_target!r}. Known IDs: "
f"{', '.join(sorted(valid_ids)) or '(none)'}",
_Ansi.RED,
))
sys.exit(2)
if ack_advisory(ack_target):
print(color(
f" ✓ Acknowledged advisory {ack_target}. "
f"It will no longer trigger startup banners.",
_Ansi.GREEN,
))
else:
print(color(
f" ✗ Failed to persist ack for {ack_target}. "
f"Check ~/.hermes/config.yaml is writable.",
_Ansi.RED,
))
sys.exit(1)
+94
View File
@@ -0,0 +1,94 @@
"""Terminal output rendering for doctor diagnostics.
Pure stdlib uses inline ANSI codes instead of importing hermes_cli.colors.
"""
from __future__ import annotations
import os
import sys
def _should_use_color() -> bool:
"""Return True when colored output is appropriate.
Respects NO_COLOR (https://no-color.org/) and TERM=dumb.
"""
if os.environ.get("NO_COLOR") is not None:
return False
if os.environ.get("TERM") == "dumb":
return False
try:
if not sys.stdout.isatty():
return False
except (AttributeError, ValueError):
return False
return True
class _Ansi:
"""ANSI escape code constants."""
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
MAGENTA = "\033[35m"
CYAN = "\033[36m"
def color(text: str, *codes: str) -> str:
"""Apply ANSI color codes to text (only when color output is appropriate)."""
if not _should_use_color():
return text
return "".join(codes) + text + _Ansi.RESET
def check_ok(text: str, detail: str = "") -> None:
"""Print an OK check line."""
glyph = color("", _Ansi.GREEN)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_warn(text: str, detail: str = "") -> None:
"""Print a warning check line."""
glyph = color("", _Ansi.YELLOW)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_fail(text: str, detail: str = "") -> None:
"""Print a failure check line."""
glyph = color("", _Ansi.RED)
line = f" {glyph} {text}"
if detail:
line += f" {color(detail, _Ansi.DIM)}"
print(line)
def check_info(text: str) -> None:
"""Print an informational check line."""
glyph = color("", _Ansi.CYAN)
print(f" {glyph} {text}")
def section(title: str) -> None:
"""Print a section banner: blank line + bold cyan ◆ title."""
print()
print(color(f"{title}", _Ansi.CYAN, _Ansi.BOLD))
def print_banner() -> None:
"""Print the doctor header banner."""
print()
print(color("┌─────────────────────────────────────────────────────────┐", _Ansi.CYAN))
print(color("│ 🩺 Hermes Doctor │", _Ansi.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", _Ansi.CYAN))
print()
+356
View File
@@ -0,0 +1,356 @@
"""Check registration and diagnostic reporting framework.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from typing import Callable
from hermes_cli.doctor._output import (
check_ok,
check_warn,
check_fail,
check_info,
section as _print_section,
color,
_Ansi,
)
# ── Registry ──────────────────────────────────────────────────────────────
@dataclass
class RegisteredCheck:
section: str
name: str
fn: Callable
priority: int = 0
_CHECKS: list[RegisteredCheck] = []
def register(section: str, name: str = "", priority: int = 0) -> Callable:
"""Decorator to register a diagnostic check.
Args:
section: Section heading this check appears under.
name: Short identifier for this specific sub-check.
Used in the auto-caught exception message.
Defaults to the function name.
priority: Run order within the section lower runs first (default 0).
The decorated function receives a single ``DiagnosticReport`` argument.
Any uncaught exception is caught by the runner, which emits a warning
and continues checks don't need defensive blanket try/except.
Example::
@register("Python Environment", "python-version")
def check_python_version(report):
py = sys.version_info
if py >= (3, 11):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
else:
report.fail(
"Python too old",
detail="(3.11+ required)",
fix="Upgrade Python to 3.10+",
)
For auto-fixable issues pass a ``fix_fn`` callable to ``report.fail`` or
``report.add_issue``::
@register("Config Files", "env-file")
def check_env_file(report):
if not env_path.exists():
def _fix(report):
env_path.touch()
report.ok("Created empty .env")
report.fail(".env missing", fix="run hermes setup", fix_fn=_fix)
"""
def decorator(fn: Callable) -> Callable:
_CHECKS.append(RegisteredCheck(
section=section,
name=name or fn.__name__,
fn=fn,
priority=priority,
))
return fn
return decorator
def get_registered_checks() -> list[RegisteredCheck]:
"""Return all registered checks in run order."""
seen: dict[str, int] = {}
order = 0
for c in _CHECKS:
if c.section not in seen:
seen[c.section] = order
order += 1
return sorted(_CHECKS, key=lambda c: (seen[c.section], c.priority, c.name))
# ── Issue record ──────────────────────────────────────────────────────────
@dataclass
class _Issue:
"""An issue collected during a check run."""
text: str
fix_fn: Callable | None = None # None → manual-only
section: str = ""
check: str = ""
@dataclass
class _Warning:
"""A warning collected during a check run."""
text: str
section: str = ""
check: str = ""
# ── Diagnostic Report ────────────────────────────────────────────────────
class DiagnosticReport:
"""Passed to every check function.
Section headers are deferred: a header only prints when the first
finding (ok/warn/fail/info) is emitted under it, so checks that
return early without output never print stray banners.
Fix model
---------
Every ``fail()`` and ``add_issue()`` call accepts an optional
``fix_fn`` and an optional human-readable ``fix`` string.
* ``fix_fn`` zero-arg callable executed when ``--fix`` is active.
It receives the report so it can emit ok/warn/fail/info
lines describing what it did.
* ``fix`` short instruction shown in the issue summary when
``--fix`` is NOT active or when no ``fix_fn`` was given.
In ``--fix`` mode:
- issues *with* a ``fix_fn`` are executed immediately; the issue
is removed from the summary if the fn succeeds.
- issues *without* a ``fix_fn`` remain in the summary as manual.
In normal mode:
- issues *with* a ``fix_fn`` are shown as "✦ fixable — re-run with --fix"
- issues *without* a ``fix_fn`` are shown as "✗ manual"
"""
def __init__(self, should_fix: bool = False) -> None:
self._should_fix = should_fix
self._issues: list[_Issue] = []
self._fixed_issues: list[_Issue] = []
self._warnings: list[_Warning] = []
self._fixed: int = 0
self._pending_section: str | None = None
self._printed_section: str | None = None
# Set by the runner before each check so findings know their origin
self._current_section: str = ""
self._current_check: str = ""
# ── Section management ────────────────────────────────────────────────
def section(self, title: str) -> None:
"""Declare the current section (header is deferred until first output)."""
self._pending_section = title
self._current_section = title
def _flush_section(self) -> None:
if self._pending_section and self._pending_section != self._printed_section:
_print_section(self._pending_section)
self._printed_section = self._pending_section
# ── Finding emitters ─────────────────────────────────────────────────
def ok(self, text: str, detail: str = "") -> None:
self._flush_section()
check_ok(text, detail)
def warn(self, text: str, detail: str = "") -> None:
self._flush_section()
check_warn(text, detail)
label = text + (f" {detail}" if detail else "")
self._warnings.append(_Warning(label, self._current_section, self._current_check))
def fail(
self,
text: str,
detail: str = "",
*,
fix: str = "",
fix_fn: Callable | None = None,
) -> None:
"""Emit a ✗ failure line and record the issue.
Args:
text: Primary description of the problem.
detail: Optional dim detail suffix on the same line.
fix: Short human-readable instruction shown in the summary.
fix_fn: Zero-arg callable that auto-fixes the problem when
called. It receives this report for output. If
``--fix`` is active it is called immediately;
otherwise the issue is annotated as auto-fixable.
"""
self._flush_section()
check_fail(text, detail)
self._record_issue(fix or text, fix_fn)
def info(self, text: str) -> None:
self._flush_section()
check_info(text)
def add_issue(
self,
text: str,
*,
fix_fn: Callable | None = None,
) -> None:
"""Add an issue to the summary without printing a fail line.
Use when a check wants to register a problem that was already
surfaced via warn() but still deserves a summary entry.
Args:
text: Issue description for the summary.
fix_fn: Optional auto-fix callable (same semantics as fail).
"""
self._record_issue(text, fix_fn)
# ── Internal ─────────────────────────────────────────────────────────
def _record_issue(self, text: str, fix_fn: Callable | None) -> None:
issue = _Issue(text, fix_fn, self._current_section, self._current_check)
if self._should_fix and fix_fn is not None:
try:
fix_fn(self)
self._fixed += 1
self._fixed_issues.append(issue)
return
except Exception as exc:
check_warn(f"Auto-fix failed", f"({type(exc).__name__}: {exc})")
# Fall through and add to remaining issues
self._issues.append(issue)
# ── Colour helpers ────────────────────────────────────────────────────
def color(self, text: str, *codes: str) -> str:
return color(text, *codes)
GREEN = _Ansi.GREEN
YELLOW = _Ansi.YELLOW
RED = _Ansi.RED
CYAN = _Ansi.CYAN
DIM = _Ansi.DIM
BOLD = _Ansi.BOLD
def raw_print(self, text: str = "") -> None:
self._flush_section()
print(text)
# ── Summary ───────────────────────────────────────────────────────────
def print_summary(self) -> None:
fixable = [i for i in self._issues if i.fix_fn is not None]
manual = [i for i in self._issues if i.fix_fn is None]
print()
# ── --fix mode: report what was done, then leftover sections ─────
if self._should_fix and self._fixed > 0:
if not self._issues:
print(color("" * 60, _Ansi.GREEN))
print(color(
f" ✓ Fixed {self._fixed} issue(s). All checks passed! 🎉",
_Ansi.GREEN, _Ansi.BOLD,
))
print()
_render_grouped(self._fixed_issues, bullet="")
print()
return
print(color("" * 60, _Ansi.YELLOW))
print(
color(f" ✓ Fixed {self._fixed} issue(s).", _Ansi.GREEN, _Ansi.BOLD)
+ color(f" {len(self._issues)} still require attention.", _Ansi.YELLOW)
)
print()
_render_grouped(self._fixed_issues, bullet="")
print()
# ── All-clear ────────────────────────────────────────────────────
elif not self._issues:
print(color("" * 60, _Ansi.GREEN))
print(color(" All checks passed! 🎉", _Ansi.GREEN, _Ansi.BOLD))
print()
return
# ── Header (no-fix mode) ─────────────────────────────────────────
else:
print(color("" * 60, _Ansi.YELLOW))
# ── Auto-fixable issues ──────────────────────────────────────────
if fixable:
print(color(
f"{len(fixable)} auto-fixable issue{"" if len(fixable) == 1 else "s"}"
+ (" — run `hermes doctor --fix` to resolve:" if not self._should_fix else ":"),
_Ansi.CYAN, _Ansi.BOLD,
))
_render_grouped(fixable)
print()
# ── Manual issues ────────────────────────────────────────────────
if manual:
print(color(f"{len(manual)} issue{"" if len(manual) == 1 else "s"} require manual attention:", _Ansi.RED, _Ansi.BOLD))
_render_grouped(manual)
print()
# ── Warnings (informational, not blocking) ───────────────────────
if self._warnings:
print(color(f"{len(self._warnings)} warning{"" if len(self._warnings) == 1 else "s"}:", _Ansi.YELLOW, _Ansi.BOLD))
_render_grouped(self._warnings)
print()
def _fmt_label(section: str, check: str) -> str:
"""Unused — kept for any external callers; grouping is now done in print_summary."""
return ""
def _render_grouped(items: list, bullet: str = "") -> None:
"""Render a list of _Issue or _Warning grouped by section, with sub-headers."""
# Preserve insertion order of sections
seen: dict[str, list] = {}
for item in items:
key = item.section or ""
seen.setdefault(key, []).append(item)
for section_name, group in seen.items():
if section_name:
print(f" {color(section_name, _Ansi.DIM)}")
for item in group:
text = item.text if hasattr(item, "text") else item
print(f" {bullet} {text}")
# ── Runner ────────────────────────────────────────────────────────────────
def run_checks(report: DiagnosticReport) -> None:
"""Run all registered checks, catching any unexpected exceptions."""
for check in get_registered_checks():
report.section(check.section)
report._current_section = check.section
report._current_check = check.name
try:
check.fn(report)
except Exception as exc:
report.warn(
f"Check '{check.name}' failed unexpectedly",
f"({type(exc).__name__}: {exc})",
)
+65
View File
@@ -0,0 +1,65 @@
"""Core types for the doctor diagnostic framework.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Callable
class Severity(Enum):
"""Diagnostic severity levels."""
OK = "ok"
INFO = "info"
WARN = "warn"
FAIL = "fail"
@dataclass
class Finding:
"""A single diagnostic finding from a check."""
severity: Severity
text: str
detail: str = ""
fix: str = "" # Fix instruction for the summary
auto_fixable: bool = False
# Convenience constructors
def ok(text: str, detail: str = "") -> Finding:
"""Create an OK finding."""
return Finding(Severity.OK, text, detail)
def info(text: str) -> Finding:
"""Create an informational finding."""
return Finding(Severity.INFO, text)
def warn(text: str, detail: str = "") -> Finding:
"""Create a warning finding."""
return Finding(Severity.WARN, text, detail)
def fail(text: str, detail: str = "", fix: str = "") -> Finding:
"""Create a failure finding with optional fix instruction."""
return Finding(Severity.FAIL, text, detail, fix)
@dataclass
class CheckResult:
"""Result from running a single check function."""
section: str
findings: list[Finding] = field(default_factory=list)
@dataclass
class Check:
"""A registered diagnostic check."""
name: str
section: str
fn: Callable # (ctx: DoctorContext, report: DiagnosticReport) -> None
priority: int = 0 # Ordering within section (lower = earlier)
+19
View File
@@ -0,0 +1,19 @@
"""Doctor checks package — importing this registers all checks."""
from hermes_cli.doctor.checks import ( # noqa: F401 — side-effect imports
python_env,
security,
dep_mgmt,
config_files,
xai_retirement,
auth_providers,
directory_structure,
gateway_service,
command_install,
external_tools,
api_connectivity,
tool_availability,
skills_hub,
memory_provider,
profiles,
)
+123
View File
@@ -0,0 +1,123 @@
"""Shared utility functions for doctor checks.
Pure stdlib no external dependencies.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
def _safe_which(cmd: str) -> str | None:
"""shutil.which wrapper resilient to platform monkeypatching in tests."""
try:
return shutil.which(cmd)
except Exception:
return None
def is_termux() -> bool:
"""Return True when running inside Termux on Android."""
return bool(
os.environ.get("TERMUX_VERSION")
or "com.termux/files" in os.environ.get("PREFIX", "")
)
# Re-export as the private name tests expect
_is_termux = is_termux
def python_install_cmd() -> str:
"""Return the pip install command appropriate for the platform."""
return "python -m pip install" if is_termux() else "uv pip install"
def system_package_install_cmd(pkg: str) -> str:
"""Return the package manager install command for the given package."""
if is_termux():
return f"pkg install {pkg}"
if sys.platform == "darwin":
return f"brew install {pkg}"
return f"sudo apt install {pkg}"
def termux_browser_setup_steps(node_installed: bool) -> list[str]:
"""Return ordered setup steps for browser tools on Termux."""
steps: list[str] = []
step = 1
if not node_installed:
steps.append(f"{step}) pkg install nodejs")
step += 1
steps.append(f"{step}) npm install -g agent-browser")
steps.append(f"{step + 1}) agent-browser install")
return steps
def termux_install_all_fallback_notes() -> list[str]:
"""Return informational notes for Termux compatibility."""
return [
"Termux install profile: use .[termux-all] for broad compatibility (installer default on Termux).",
"Matrix E2EE extra is excluded on Termux (python-olm currently fails to build).",
"Local faster-whisper extra is excluded on Termux (ctranslate2/av build path unavailable).",
"STT fallback: use Groq Whisper (set GROQ_API_KEY) or OpenAI Whisper (set VOICE_TOOLS_OPENAI_KEY).",
]
def resolve_project_root() -> Path:
"""Resolve the hermes-agent project root directory.
Lightweight stdlib-only resolution: walks up from this file to find
pyproject.toml, which marks the project root.
"""
# This file is at hermes_cli/doctor/checks/_helpers.py
# Project root is 4 levels up
here = Path(__file__).resolve()
candidate = here.parent.parent.parent.parent # hermes_cli/doctor/checks/_helpers.py -> hermes_cli -> project root
# Verify by checking pyproject.toml or setup.py exists
if (candidate / "pyproject.toml").exists() or (candidate / "setup.py").exists():
return candidate
# Fallback: try parent.parent.parent (if the file moved)
for parent in here.parents:
if (parent / "pyproject.toml").exists():
return parent
# Last resort
return candidate
def resolve_hermes_home() -> Path:
"""Resolve the Hermes home directory.
Reads HERMES_HOME env var, falls back to platform-native default.
Lightweight stdlib-only implementation.
"""
val = os.environ.get("HERMES_HOME", "").strip()
if val:
return Path(val)
if sys.platform == "win32":
local_appdata = os.environ.get("LOCALAPPDATA", "").strip()
base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local"
return base / "hermes"
# Check for active profile
default_home = Path.home() / ".hermes"
try:
active_path = default_home / "active_profile"
active = active_path.read_text().strip() if active_path.exists() else ""
except (OSError, UnicodeDecodeError):
active = ""
if active and active != "default":
return default_home / "profiles" / active
return default_home
def resolve_display_hermes_home() -> str:
"""Return a user-friendly display path for HERMES_HOME."""
home = resolve_hermes_home()
try:
rel = home.relative_to(Path.home())
return f"~/{rel}"
except ValueError:
return str(home)
@@ -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,
)
+334
View File
@@ -0,0 +1,334 @@
"""Configuration file checks."""
from __future__ import annotations
import os
import shutil
from hermes_cli.doctor._registry import register
_PROVIDER_ENV_HINTS = (
"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
"OPENAI_BASE_URL", "NOUS_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY",
"KIMI_API_KEY", "KIMI_CN_API_KEY", "GMI_API_KEY", "MINIMAX_API_KEY",
"MINIMAX_CN_API_KEY", "KILOCODE_API_KEY", "DEEPSEEK_API_KEY", "DASHSCOPE_API_KEY",
"HF_TOKEN", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", "XIAOMI_API_KEY",
"TOKENHUB_API_KEY",
)
def _has_provider_env_config(content: str) -> bool:
return any(key in content for key in _PROVIDER_ENV_HINTS)
@register("Configuration Files", "env-file", priority=10)
def check_env_file(report):
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
env_path = HERMES_HOME / ".env"
if env_path.exists():
report.ok(f"{_DHH}/.env file exists")
content = env_path.read_text(encoding="utf-8")
if _has_provider_env_config(content):
report.ok("API key or custom endpoint configured")
else:
report.warn(f"No API key found in {_DHH}/.env")
report.add_issue("run 'hermes setup' to configure API keys")
elif (PROJECT_ROOT / ".env").exists():
report.ok(".env file exists (in project directory)")
else:
def _fix(r):
env_path.parent.mkdir(parents=True, exist_ok=True)
env_path.touch()
try:
os.chmod(str(env_path), 0o600)
except OSError:
pass
r.ok(f"Created empty {_DHH}/.env")
r.info("run 'hermes setup' to configure API keys")
report.fail(
f"{_DHH}/.env file missing",
fix="run 'hermes setup' to create one",
fix_fn=_fix,
)
@register("Configuration Files", "config-yaml", priority=20)
def check_config_yaml(report):
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT, _DHH
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
fallback = PROJECT_ROOT / "cli-config.yaml"
if fallback.exists():
report.ok("cli-config.yaml exists (in project directory)")
return
def _fix(r):
config_path.parent.mkdir(parents=True, exist_ok=True)
example = PROJECT_ROOT / "cli-config.yaml.example"
if example.exists():
shutil.copy2(str(example), str(config_path))
r.ok(f"Created {_DHH}/config.yaml from cli-config.yaml.example")
else:
from hermes_cli.config import DEFAULT_CONFIG, save_config
save_config(DEFAULT_CONFIG)
r.ok(f"Created {_DHH}/config.yaml from defaults")
report.warn(
"config.yaml not found",
"(using defaults)",
)
report.add_issue(
f"{_DHH}/config.yaml missing",
fix_fn=_fix,
)
return
report.ok(f"{_DHH}/config.yaml exists")
_check_model_provider_config(report, config_path)
def _check_model_provider_config(report, config_path):
import yaml as _yaml
from hermes_cli.doctor import _DHH
cfg = _yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
model_section = cfg.get("model") or {}
provider_raw = (model_section.get("provider") or "").strip()
provider = provider_raw.lower()
default_model = (model_section.get("default") or model_section.get("model") or "").strip()
known_providers: set[str] = set()
_resolve_auth_provider = None
_normalize_catalog_provider = None
_resolve_provider_full = None
try:
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider as _rap
known_providers = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto"}
_resolve_auth_provider = _rap
except Exception:
pass
try:
from hermes_cli.providers import normalize_provider as _ncp, resolve_provider_full as _rpf
_normalize_catalog_provider = _ncp
_resolve_provider_full = _rpf
except Exception:
pass
custom_providers = []
try:
from hermes_cli.config import get_compatible_custom_providers
custom_providers = get_compatible_custom_providers(cfg) or []
except Exception:
pass
user_providers = cfg.get("providers")
if isinstance(user_providers, dict):
known_providers.update(str(n).strip().lower() for n in user_providers if str(n).strip())
for entry in custom_providers:
if isinstance(entry, dict):
n = str(entry.get("name") or "").strip()
if n:
known_providers.add("custom:" + n.lower().replace(" ", "-"))
valid_ids = set(known_providers)
if _normalize_catalog_provider:
for kp in known_providers:
try:
valid_ids.add(_normalize_catalog_provider(kp))
except Exception:
pass
accepted = {provider} if provider else set()
runtime_provider = provider
if provider and _resolve_auth_provider and provider not in {"auto", "custom"}:
try:
runtime_provider = _resolve_auth_provider(provider)
accepted.add(runtime_provider)
except Exception:
pass
catalog_provider = provider
if provider and _resolve_provider_full and provider not in {"auto", "custom"}:
pdef = _resolve_provider_full(provider, user_providers, custom_providers)
catalog_provider = pdef.id if pdef is not None else None
if catalog_provider:
accepted.add(catalog_provider)
if provider and provider != "auto":
if catalog_provider is None or (known_providers and not (accepted & valid_ids)):
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
report.fail(
f"model.provider '{provider_raw}' is not a recognised provider",
f"(known: {known_list})",
fix=f"run 'hermes config set model.provider <valid_provider>' — valid: {known_list}",
)
policy_id = str(runtime_provider or catalog_provider or "").strip().lower()
slug_ok_providers = {"openrouter", "auto", "kilocode", "opencode-zen", "huggingface", "lmstudio", "nous"}
slug_ok = policy_id in slug_ok_providers or policy_id == "custom" or policy_id.startswith("custom:")
if default_model and "/" in default_model and policy_id and not slug_ok:
report.warn(
f"model.default '{default_model}' uses a vendor/model slug but provider is '{provider_raw}'",
"(vendor-prefixed slugs belong to aggregators like openrouter)",
)
report.add_issue(
f"model.default '{default_model}' is vendor-prefixed for provider '{provider_raw}'"
"set model.provider to 'openrouter', or drop the vendor prefix"
)
if runtime_provider and runtime_provider not in ("auto", "custom"):
if runtime_provider == "openrouter":
from hermes_cli.config import get_env_value
configured = bool(
str(get_env_value("OPENROUTER_API_KEY") or "").strip()
or str(get_env_value("OPENAI_API_KEY") or "").strip()
)
else:
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
configured = True
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
status = get_auth_status(runtime_provider) or {}
configured = bool(status.get("configured") or status.get("logged_in") or status.get("api_key"))
if not configured:
report.fail(
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
fix=f"run 'hermes setup' or set the API key in {_DHH}/.env",
)
@register("Configuration Files", "config-version", priority=30)
def check_config_version(report):
from hermes_cli.doctor import HERMES_HOME
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
from hermes_cli.config import check_config_version as _ccv, migrate_config
current_ver, latest_ver = _ccv()
if current_ver < latest_ver:
def _fix(r):
migrate_config(interactive=False, quiet=False)
r.ok("Config migrated to latest version")
report.warn(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)")
report.add_issue(
"config.yaml is outdated — run 'hermes setup' to migrate",
fix_fn=_fix,
)
else:
report.ok(f"Config version up to date (v{current_ver})")
@register("Configuration Files", "stale-root-keys", priority=40)
def check_stale_root_keys(report):
from hermes_cli.doctor import HERMES_HOME
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
import yaml
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
stale = [k for k in ("provider", "base_url") if k in raw_config and isinstance(raw_config[k], str)]
if not stale:
return
def _fix(r):
raw_model = raw_config.get("model")
if isinstance(raw_model, dict):
model_section = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_section = {"default": raw_model.strip()}
raw_config["model"] = model_section
else:
model_section = {}
raw_config["model"] = model_section
for k in stale:
if not model_section.get(k):
model_section[k] = raw_config.pop(k)
else:
raw_config.pop(k)
from utils import atomic_yaml_write
atomic_yaml_write(config_path, raw_config)
r.ok("Migrated stale root-level keys into model section")
report.warn(
f"Stale root-level config keys: {', '.join(stale)}",
"(should be under 'model:' section)",
)
report.add_issue(
f"stale root-level keys {stale} in config.yaml",
fix_fn=_fix,
)
@register("Configuration Files", "max-iterations-ghost", priority=50)
def check_max_iterations_ghost(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
config_path = HERMES_HOME / "config.yaml"
if not config_path.exists():
return
import yaml
from hermes_cli.config import load_env, remove_env_value
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f) or {}
agent_cfg = raw_config.get("agent")
cfg_max_turns = agent_cfg.get("max_turns") if isinstance(agent_cfg, dict) else None
if cfg_max_turns is None:
cfg_max_turns = raw_config.get("max_turns")
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
if not (cfg_max_turns is not None and env_ghost is not None
and str(cfg_max_turns).strip() != str(env_ghost).strip()):
return
def _fix(r):
if remove_env_value("HERMES_MAX_ITERATIONS"):
r.ok(
"Removed stale HERMES_MAX_ITERATIONS from .env "
f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)"
)
else:
raise RuntimeError(
f"could not remove HERMES_MAX_ITERATIONS from {_DHH}/.env — edit manually"
)
report.warn(
f"HERMES_MAX_ITERATIONS={env_ghost} in .env shadows agent.max_turns={cfg_max_turns} in config.yaml",
"(stale ghost from an earlier `hermes setup` run)",
)
report.add_issue(
"stale HERMES_MAX_ITERATIONS in .env shadows config.yaml",
fix_fn=_fix,
)
@register("Config Structure", "config-structure-validation", priority=10)
def check_config_structure(report):
from hermes_cli.config import validate_config_structure
config_issues = validate_config_structure()
if not config_issues:
return
for ci in config_issues:
if ci.severity == "error":
report.fail(ci.message)
else:
report.warn(ci.message)
for hint_line in ci.hint.splitlines():
report.info(hint_line)
report.add_issue(ci.message)
+85
View File
@@ -0,0 +1,85 @@
"""Dependency management checks: venv integrity, uv, required packages."""
from __future__ import annotations
import shutil
from hermes_cli.doctor._registry import register
from hermes_cli.doctor.checks._helpers import python_install_cmd
@register("Virtual Environment Integrity", "venv-structure", priority=10)
def check_venv_integrity(report):
from hermes_cli.managed_uv import get_venv_path, resolve_uv
venv_path = get_venv_path()
if not venv_path.exists():
report.warn("Venv directory missing", "(will be recreated on next install/update)")
return
has_uv = bool(resolve_uv() or shutil.which("uv"))
if not has_uv:
def _fix(r):
r.raw_print(" -> Attempting atomic venv recreation...")
from hermes_cli.managed_uv import recreate_venv_atomically, get_venv_path as _gvp
if recreate_venv_atomically(_gvp().parent, group="all"):
r.ok("Venv successfully recreated and swapped to uv-native state")
else:
raise RuntimeError("Recreation failed — please run the Hermes installer")
report.fail(
"Legacy pip venv detected (uv missing)",
"(dependency management will fail)",
fix="run `hermes doctor --fix` to recreate",
fix_fn=_fix,
)
else:
report.ok("Venv structure valid and uv-native")
@register("Dependency Management", "uv-available", priority=10)
def check_uv_available(report):
from hermes_cli.managed_uv import resolve_uv
uv_bin = resolve_uv()
if uv_bin:
report.ok(f"Managed uv available ({uv_bin})")
else:
path_uv = shutil.which("uv")
if path_uv:
report.ok(f"System uv available ({path_uv})")
else:
report.fail(
"uv is missing",
"(dependency installation will fail. Install uv via the Hermes installer, or `pkg install uv` on Termux)",
)
@register("Required Packages", "required-packages", priority=10)
def check_required_packages(report):
required = [
("openai", "OpenAI SDK"),
("rich", "Rich (terminal UI)"),
("dotenv", "python-dotenv"),
("yaml", "PyYAML"),
("httpx", "HTTPX"),
]
optional = [
("croniter", "Croniter (cron expressions)"),
("telegram", "python-telegram-bot"),
("discord", "discord.py"),
]
install_cmd = python_install_cmd()
for module, name in required:
try:
__import__(module)
report.ok(name)
except ImportError:
report.fail(name, "(missing)", fix=f"Install: {install_cmd} {module}")
for module, name in optional:
try:
__import__(module)
report.ok(name, "(optional)")
except ImportError:
report.warn(name, "(optional, not installed)")
@@ -0,0 +1,155 @@
"""Directory structure and state file checks."""
from __future__ import annotations
import sqlite3
from pathlib import Path
from hermes_cli.doctor._registry import register
@register("Directory Structure", "hermes-home", priority=10)
def check_hermes_home_dir(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
if not HERMES_HOME.exists():
def _fix(r):
HERMES_HOME.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH} directory")
report.warn(f"{_DHH} not found", "(will be created on first use)")
report.add_issue(f"{_DHH} directory missing", fix_fn=_fix)
else:
report.ok(f"{_DHH} directory exists")
for subdir in ("cron", "sessions", "logs", "skills", "memories"):
p = HERMES_HOME / subdir
if not p.exists():
def _fix(r, _p=p, _s=subdir):
_p.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH}/{_s}/")
report.warn(f"{_DHH}/{subdir}/ not found", "(will be created on first use)")
report.add_issue(f"{_DHH}/{subdir}/ missing", fix_fn=_fix)
else:
report.ok(f"{_DHH}/{subdir}/ exists")
@register("Directory Structure", "soul-md", priority=20)
def check_soul_md(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
soul_path = HERMES_HOME / "SOUL.md"
if soul_path.exists():
content = soul_path.read_text(encoding="utf-8").strip()
lines = [l for l in content.splitlines()
if l.strip() and not l.strip().startswith(("<!--", "-->", "#"))]
if lines:
report.ok(f"{_DHH}/SOUL.md exists (persona configured)")
else:
report.info(f"{_DHH}/SOUL.md exists but is empty — edit it to customize personality")
else:
def _fix(r):
soul_path.parent.mkdir(parents=True, exist_ok=True)
soul_path.write_text(
"# Hermes Agent Persona\n\n"
"<!-- Edit this file to customize how Hermes communicates. -->\n\n"
"You are Hermes, a helpful AI assistant.\n",
encoding="utf-8",
)
r.ok(f"Created {_DHH}/SOUL.md with basic template")
report.warn(f"{_DHH}/SOUL.md not found", "(create it to give Hermes a custom personality)")
report.add_issue(f"{_DHH}/SOUL.md missing", fix_fn=_fix)
@register("Directory Structure", "memories-dir", priority=30)
def check_memories_dir(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
memories_dir = HERMES_HOME / "memories"
if not memories_dir.exists():
def _fix(r):
memories_dir.mkdir(parents=True, exist_ok=True)
r.ok(f"Created {_DHH}/memories/")
report.warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
report.add_issue(f"{_DHH}/memories/ missing", fix_fn=_fix)
return
report.ok(f"{_DHH}/memories/ directory exists")
for fname in ("MEMORY.md", "USER.md"):
fpath = memories_dir / fname
if fpath.exists():
size = len(fpath.read_text(encoding="utf-8").strip())
report.ok(f"{fname} exists ({size} chars)")
else:
report.info(f"{fname} not created yet (will be created when the agent first writes a memory)")
@register("Directory Structure", "state-db", priority=40)
def check_state_db(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
db_path = HERMES_HOME / "state.db"
if not db_path.exists():
report.info(f"{_DHH}/state.db not created yet (will be created on first session)")
return
try:
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
conn.close()
report.ok(f"{_DHH}/state.db exists ({count} sessions)")
except Exception as e:
from hermes_state import is_malformed_db_error, repair_state_db_schema
if is_malformed_db_error(e):
def _fix(r):
db_report = repair_state_db_schema(db_path)
if not db_report.get("repaired"):
raise RuntimeError(
f"{db_report.get('error')}; backup at {db_report.get('backup_path')}"
)
try:
conn = sqlite3.connect(str(db_path))
count = conn.execute("SELECT COUNT(*) FROM sessions").fetchone()[0]
conn.close()
except Exception:
count = "?"
backup = Path(db_report["backup_path"]).name if db_report.get("backup_path") else "n/a"
r.ok(
f"Repaired state.db schema ({count} sessions recovered)",
f"(strategy: {db_report.get('strategy')}; backup: {backup})",
)
report.warn(
f"{_DHH}/state.db schema is malformed (sessions hidden until repaired)",
f"({e})",
)
report.add_issue("state.db schema malformed", fix_fn=_fix)
else:
report.warn(f"{_DHH}/state.db exists but has issues: {e}")
@register("Directory Structure", "wal-file", priority=50)
def check_wal_file(report):
from hermes_cli.doctor import HERMES_HOME, _DHH
db_path = HERMES_HOME / "state.db"
wal_path = HERMES_HOME / "state.db-wal"
if not wal_path.exists() or not db_path.exists():
return
wal_size = wal_path.stat().st_size
if wal_size > 50 * 1024 * 1024:
def _fix(r):
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close()
new_size = wal_path.stat().st_size if wal_path.exists() else 0
r.ok(f"WAL checkpoint performed ({wal_size // 1024}K → {new_size // 1024}K)")
report.warn(
f"WAL file is large ({wal_size // (1024 * 1024)} MB)",
"(may indicate missed checkpoints)",
)
report.add_issue("large WAL file — checkpoint needed", fix_fn=_fix)
elif wal_size > 10 * 1024 * 1024:
report.info(f"WAL file is {wal_size // (1024 * 1024)} MB (normal for active sessions)")
+274
View File
@@ -0,0 +1,274 @@
"""External tool checks: git, ripgrep, docker, ssh, daytona, node, npm audit."""
from __future__ import annotations
import os
import subprocess
import sys
from hermes_cli.doctor._registry import register
from hermes_cli.doctor.checks._helpers import (
_safe_which,
is_termux,
system_package_install_cmd,
termux_browser_setup_steps,
termux_install_all_fallback_notes,
)
@register("External Tools", "git", priority=10)
def check_git(report):
if _safe_which("git"):
report.ok("git")
else:
report.warn("git not found", "(hermes update cannot work)")
@register("External Tools", "ripgrep", priority=20)
def check_ripgrep(report):
if _safe_which("rg"):
report.ok("ripgrep (rg)", "(faster file search)")
else:
report.warn("ripgrep (rg) not found", "(file search uses grep fallback)")
report.info(f"Install for faster search: {system_package_install_cmd('ripgrep')}")
@register("External Tools", "docker", priority=30)
def check_docker(report):
from hermes_cli.doctor import PROJECT_ROOT
terminal_env = os.getenv("TERMINAL_ENV", "local")
running_in_container = False
try:
from hermes_constants import is_container as _is_container
running_in_container = _is_container()
except Exception:
pass
if running_in_container and terminal_env != "docker":
report.info(
"Running inside a container — using local terminal backend "
"(docker-in-docker is not configured by default)"
)
return
if terminal_env == "docker":
if not _safe_which("docker"):
report.fail(
"docker not found",
"(required for TERMINAL_ENV=docker)",
fix="Install Docker or change TERMINAL_ENV",
)
return
try:
res = subprocess.run(["docker", "info"], capture_output=True, timeout=10)
except subprocess.TimeoutExpired:
res = None
if res is not None and res.returncode == 0:
report.ok("docker", "(daemon running)")
else:
report.fail("docker daemon not running", "", fix="Start Docker daemon")
elif _safe_which("docker"):
report.ok("docker", "(optional)")
elif is_termux():
report.info("Docker backend is not available inside Termux (expected on Android)")
else:
report.warn("docker not found", "(optional)")
@register("External Tools", "ssh-backend", priority=40)
def check_ssh_backend(report):
terminal_env = os.getenv("TERMINAL_ENV", "local")
if terminal_env != "ssh":
return
ssh_host = os.getenv("TERMINAL_SSH_HOST")
if not ssh_host:
report.fail(
"TERMINAL_SSH_HOST not set",
"(required for TERMINAL_ENV=ssh)",
fix="Set TERMINAL_SSH_HOST in .env",
)
return
ssh_user = os.getenv("TERMINAL_SSH_USER")
ssh_port = os.getenv("TERMINAL_SSH_PORT")
ssh_key = os.getenv("TERMINAL_SSH_KEY")
target = f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host
cmd = ["ssh", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes"]
if ssh_port:
cmd += ["-p", ssh_port]
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [target, "echo ok"]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except subprocess.TimeoutExpired:
res = None
if res is not None and res.returncode == 0:
report.ok(f"SSH connection to {ssh_host}")
else:
report.fail(
f"SSH connection to {ssh_host}", "",
fix=f"Check SSH configuration for {ssh_host}",
)
@register("External Tools", "daytona-backend", priority=50)
def check_daytona_backend(report):
if os.getenv("TERMINAL_ENV", "local") != "daytona":
return
if not os.getenv("DAYTONA_API_KEY"):
report.fail(
"DAYTONA_API_KEY not set",
"(required for TERMINAL_ENV=daytona)",
fix="Set DAYTONA_API_KEY environment variable",
)
else:
report.ok("Daytona API key", "(configured)")
try:
from daytona import Daytona # noqa: F401
report.ok("daytona SDK", "(installed)")
except ImportError:
report.fail(
"daytona SDK not installed",
"(pip install daytona)",
fix="Install daytona SDK: pip install daytona",
)
@register("External Tools", "node-and-browser", priority=60)
def check_node_and_browser(report):
from hermes_cli.doctor import PROJECT_ROOT
if not _safe_which("node"):
if is_termux():
report.info("Node.js not found (browser tools are optional in the tested Termux path)")
report.info("Install Node.js on Termux with: pkg install nodejs")
report.info("Termux browser setup:")
for step in termux_browser_setup_steps(node_installed=False):
report.info(step)
else:
report.warn("Node.js not found", "(optional, needed for browser tools)")
return
report.ok("Node.js")
agent_browser_ok = False
if (PROJECT_ROOT / "node_modules" / "agent-browser").exists():
report.ok("agent-browser (Node.js)", "(browser automation)")
agent_browser_ok = True
elif _safe_which("agent-browser"):
report.ok("agent-browser", "(browser automation)")
agent_browser_ok = True
elif is_termux():
report.info("agent-browser is not installed (expected in the tested Termux path)")
report.info("Install it manually later with: npm install -g agent-browser && agent-browser install")
report.info("Termux browser setup:")
for step in termux_browser_setup_steps(node_installed=True):
report.info(step)
else:
report.warn("agent-browser not installed", "(run: npm install)")
if agent_browser_ok and not is_termux():
from tools.browser_tool import (
_chromium_installed,
_is_camofox_mode,
_get_cloud_provider,
_get_cdp_override,
_using_lightpanda_engine,
)
skip = (
_is_camofox_mode()
or bool(_get_cdp_override())
or _get_cloud_provider() is not None
or _using_lightpanda_engine()
)
if not skip:
if _chromium_installed():
report.ok("Playwright Chromium", "(browser engine)")
else:
report.warn(
"Playwright Chromium not installed",
"(browser_* tools will be hidden from the agent)",
)
install_cmd = (
f"cd {PROJECT_ROOT} && npx playwright install chromium"
if sys.platform == "win32"
else f"cd {PROJECT_ROOT} && npx playwright install --with-deps chromium"
)
report.info(f"Install with: {install_cmd}")
@register("External Tools", "npm-audit", priority=70)
def check_npm_audit(report):
import json
from hermes_cli.doctor import PROJECT_ROOT
npm_bin = _safe_which("npm")
if not npm_bin:
return
audit_targets = [
(PROJECT_ROOT, "Browser tools (agent-browser)", ["--workspaces=false"]),
(PROJECT_ROOT, "web workspace", ["--workspace", "web"]),
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
(PROJECT_ROOT / "scripts" / "whatsapp-bridge", "WhatsApp bridge", []),
]
for npm_dir, label, extra in audit_targets:
check_dir = PROJECT_ROOT if extra else npm_dir
if not (check_dir / "node_modules").exists():
continue
# best-effort: failures are silently ignored
try:
res = subprocess.run(
[npm_bin, "audit", "--json", *extra],
cwd=str(npm_dir),
capture_output=True, text=True, timeout=30,
)
data = json.loads(res.stdout) if res.stdout.strip() else {}
except Exception:
continue
vc = data.get("metadata", {}).get("vulnerabilities", {})
critical = vc.get("critical", 0)
high = vc.get("high", 0)
moderate = vc.get("moderate", 0)
total = critical + high + moderate
if extra and extra[0] == "--workspace":
fix_cmd = f"cd {npm_dir} && npm audit fix {' '.join(extra)}"
elif extra == ["--workspaces=false"]:
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
else:
fix_cmd = f"cd {npm_dir} && npm audit fix"
if total == 0:
report.ok(f"{label} deps", "(no known vulnerabilities)")
elif critical > 0 or high > 0:
report.warn(
f"{label} deps",
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})",
)
report.add_issue(
f"{label} has {total} npm "
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
)
else:
report.ok(
f"{label} deps",
f"({moderate} moderate {'vulnerability' if moderate == 1 else 'vulnerabilities'})",
)
@register("External Tools", "termux-notes", priority=80)
def check_termux_notes(report):
if not is_termux():
return
report.info("Termux compatibility fallbacks:")
for note in termux_install_all_fallback_notes():
report.info(note)
@@ -0,0 +1,54 @@
"""Gateway service checks."""
from __future__ import annotations
import os
from hermes_cli.doctor._registry import register
@register("Gateway Service", "linger-check", priority=10)
def check_gateway_service_linger(report):
from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux
from hermes_cli.service_manager import detect_service_manager
if not is_linux() or detect_service_manager() == "s6":
return
unit_path = get_systemd_unit_path()
if not unit_path.exists():
return
linger_enabled, linger_detail = get_systemd_linger_status()
if linger_enabled is True:
report.ok("Systemd linger enabled", "(gateway service survives logout)")
elif linger_enabled is False:
report.warn("Systemd linger disabled", "(gateway may stop after logout)")
report.info("Run: sudo loginctl enable-linger $USER")
report.add_issue("Enable linger for the gateway user service: sudo loginctl enable-linger $USER")
else:
report.warn("Could not verify systemd linger", f"({linger_detail})")
@register("s6 Supervision", "s6-supervision", priority=10)
def check_s6_supervision(report):
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
if detect_service_manager() != "s6":
return
mgr = S6ServiceManager()
for static in ("main-hermes", "dashboard"):
if mgr.is_running(static):
report.ok(f"{static}: up")
else:
report.info(f"{static}: down (expected if not enabled via env)")
profiles = mgr.list_profile_gateways()
if not profiles:
report.info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
return
up = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
suffix = f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else ""
report.ok(f"Per-profile gateways: {up}/{len(profiles)} supervised up{suffix}")
+36
View File
@@ -0,0 +1,36 @@
"""GitHub auth check."""
from __future__ import annotations
import subprocess
from hermes_cli.doctor._registry import register
@register("Skills Hub", priority=20)
def check_github_auth(report):
"""Check GitHub authentication status."""
from hermes_cli.doctor import _DHH
try:
from hermes_cli.config import get_env_value
except Exception:
return
def _gh_authenticated() -> bool:
try:
result = subprocess.run(
["gh", "auth", "status", "--json", "authenticated"],
capture_output=True, timeout=10,
)
return result.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
if github_token:
report.ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
@@ -0,0 +1,73 @@
"""Memory provider checks."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
@register("Memory Provider", "memory-provider", priority=10)
def check_memory_provider(report):
from hermes_cli.doctor import HERMES_HOME
provider = ""
import yaml as _yaml
cfg_path = HERMES_HOME / "config.yaml"
if cfg_path.exists():
with open(cfg_path, encoding="utf-8") as f:
raw = _yaml.safe_load(f) or {}
provider = (raw.get("memory") or {}).get("provider", "")
if not provider:
report.ok("Built-in memory active", "(no external provider configured — this is fine)")
elif provider == "honcho":
_check_honcho(report)
elif provider == "mem0":
_check_mem0(report)
else:
_check_generic(report, provider)
def _check_honcho(report):
from plugins.memory.honcho.client import HonchoClientConfig, resolve_config_path
hcfg = HonchoClientConfig.from_global_config()
cfg_path = resolve_config_path()
if not cfg_path.exists():
if hcfg.api_key or hcfg.base_url:
report.ok("Honcho configured via environment variables",
f"config file {cfg_path} not found, using HONCHO_API_KEY env var")
else:
report.warn("Honcho config not found", "run: hermes memory setup")
elif not hcfg.enabled:
report.info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
report.fail("Honcho API key or base URL not set", "run: hermes memory setup",
fix="No Honcho API key — run 'hermes memory setup'")
else:
from plugins.memory.honcho.client import get_honcho_client, reset_honcho_client
reset_honcho_client()
get_honcho_client(hcfg)
report.ok("Honcho connected",
f"workspace={hcfg.workspace_id} mode={hcfg.recall_mode} freq={hcfg.write_frequency}")
def _check_mem0(report):
from plugins.memory.mem0 import _load_config as _lc
cfg = _lc()
if cfg.get("api_key"):
report.ok("Mem0 API key configured")
report.info(f"user_id={cfg.get('user_id', '?')} agent_id={cfg.get('agent_id', '?')}")
else:
report.fail("Mem0 API key not set", "(set MEM0_API_KEY in .env or run hermes memory setup)",
fix="Mem0 is set as memory provider but API key is missing")
def _check_generic(report, provider_name):
from plugins.memory import load_memory_provider
p = load_memory_provider(provider_name)
if p and p.is_available():
report.ok(f"{provider_name} provider active")
elif p:
report.warn(f"{provider_name} configured but not available", "run: hermes memory status")
else:
report.warn(f"{provider_name} plugin not found", "run: hermes memory setup")
+42
View File
@@ -0,0 +1,42 @@
"""Named profiles check."""
from __future__ import annotations
import re
from hermes_cli.doctor._registry import register
@register("Profiles", "named-profiles", priority=10)
def check_profiles(report):
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
named = [p for p in list_profiles() if not p.is_default]
if not named:
return
report.ok(f"{len(named)} profile(s) found")
wrapper_dir = _get_wrapper_dir()
for p in named:
parts = []
if p.gateway_running:
parts.append("gateway running")
if p.model:
parts.append(p.model[:30])
if not (p.path / "config.yaml").exists():
parts.append("⚠ missing config")
if not (p.path / ".env").exists():
parts.append("no .env")
if not (wrapper_dir / p.name).exists():
parts.append("no alias")
report.ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}")
if wrapper_dir.is_dir():
for wrapper in wrapper_dir.iterdir():
if not wrapper.is_file():
continue
content = wrapper.read_text()
if "hermes -p" in content:
m = re.search(r"hermes -p (\S+)", content)
if m and not profile_exists(m.group(1)):
report.warn(f"Orphan alias: {wrapper.name} → profile '{m.group(1)}' no longer exists")
+71
View File
@@ -0,0 +1,71 @@
"""Python environment checks."""
from __future__ import annotations
import sys
from hermes_cli.doctor._registry import register
@register("Python Environment", "python-version", priority=10)
def check_python_version(report):
py = sys.version_info
if py >= (3, 11):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
elif py >= (3, 10):
report.ok(f"Python {py.major}.{py.minor}.{py.micro}")
report.warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
else:
report.fail(
f"Python {py.major}.{py.minor}.{py.micro}",
"(3.10+ required)",
fix="Upgrade Python to 3.10+",
)
@register("Python Environment", "venv-active", priority=20)
def check_venv_active(report):
if sys.prefix != sys.base_prefix:
report.ok("Virtual environment active")
else:
report.warn("Not in virtual environment", "(recommended)")
@register("Python Environment", "version-consistency", priority=30)
def check_version_consistency(report):
"""Verify pyproject.toml version matches hermes_cli.__version__."""
from hermes_cli.doctor import PROJECT_ROOT
from hermes_cli import __version__ as init_version
pyproject = PROJECT_ROOT / "pyproject.toml"
try:
text = pyproject.read_text(encoding="utf-8")
except OSError:
return # Installed wheel — nothing to cross-check
in_project = False
pyproject_version = None
for raw in text.splitlines():
line = raw.strip()
if line.startswith("[") and line.endswith("]"):
in_project = line == "[project]"
continue
if in_project and line.startswith("version") and "=" in line:
value = line.split("=", 1)[1].split("#", 1)[0].strip().strip("\"\'")
pyproject_version = value or None
break
if pyproject_version is None:
return
if pyproject_version == init_version:
report.ok("Version files consistent", f"({init_version})")
else:
report.fail(
"Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
fix=(
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)"
),
)
+47
View File
@@ -0,0 +1,47 @@
"""Security advisory checks."""
from __future__ import annotations
from hermes_cli.doctor._registry import register
@register("Security Advisories", "security-advisories", priority=10)
def check_security_advisories(report):
from hermes_cli.security_advisories import (
detect_compromised,
filter_unacked,
full_remediation_text,
get_acked_ids,
)
all_hits = detect_compromised()
fresh_hits = filter_unacked(all_hits)
if not fresh_hits:
report.ok("No active security advisories")
return
for hit in fresh_hits:
report.fail(
f"{hit.advisory.title}",
f"({hit.package}=={hit.installed_version})",
)
for line in full_remediation_text(hit):
if line:
report.raw_print(f" {report.color(line, report.YELLOW)}")
else:
report.raw_print()
report.add_issue(
f"Resolve security advisory {hit.advisory.id}: "
f"uninstall {hit.package}=={hit.installed_version} and "
f"rotate credentials, then run "
f"`hermes doctor --ack {hit.advisory.id}`."
)
acked_ids = get_acked_ids()
for h in all_hits:
if h.advisory.id in acked_ids:
report.warn(
f"{h.package}=={h.installed_version} still installed "
f"(advisory {h.advisory.id} acknowledged)",
)
+56
View File
@@ -0,0 +1,56 @@
"""Skills Hub and GitHub auth checks."""
from __future__ import annotations
import json
import subprocess
from hermes_cli.doctor._registry import register
@register("Skills Hub", "skills-hub-dir", priority=10)
def check_skills_hub(report):
from hermes_cli.doctor import HERMES_HOME
hub_dir = HERMES_HOME / "skills" / ".hub"
if not hub_dir.exists():
report.warn("Skills Hub directory not initialized", "(run: hermes skills list)")
return
report.ok("Skills Hub directory exists")
lock_file = hub_dir / "lock.json"
if lock_file.exists():
lock_data = json.loads(lock_file.read_text())
count = len(lock_data.get("installed", {}))
report.ok(f"Lock file OK ({count} hub-installed skill(s))")
else:
report.warn("Lock file", "(corrupted or unreadable)")
quarantine = hub_dir / "quarantine"
q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
if q_count > 0:
report.warn(f"{q_count} skill(s) in quarantine", "(pending review)")
@register("Skills Hub", "github-auth", priority=20)
def check_github_auth(report):
from hermes_cli.doctor import _DHH
from hermes_cli.config import get_env_value
def _gh_authenticated() -> bool:
try:
res = subprocess.run(
["gh", "auth", "status", "--json", "authenticated"],
capture_output=True, timeout=10,
)
return res.returncode == 0
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
github_token = get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN")
if github_token:
report.ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
report.ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
report.warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
@@ -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
View File
@@ -13,9 +13,9 @@ import subprocess
import sys
from pathlib import Path
from hermes_cli.config import get_hermes_home, get_env_path, get_project_root, load_config
from hermes_cli.config import get_hermes_home, get_env_path, load_config
from hermes_cli.env_loader import load_hermes_dotenv
from hermes_constants import display_hermes_home
from hermes_constants import display_hermes_home, get_hermes_source_root
from agent.skill_utils import is_excluded_skill_path
@@ -224,10 +224,10 @@ def run_dump(args):
env_path = get_env_path()
load_hermes_dotenv(
hermes_home=env_path.parent,
project_env=get_project_root() / ".env",
project_env=get_hermes_source_root() / ".env",
)
project_root = get_project_root()
project_root = get_hermes_source_root()
hermes_home = get_hermes_home()
try:
+13 -29
View File
@@ -15,7 +15,8 @@ import textwrap
from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
from gateway.status import terminate_pid
from gateway.restart import (
@@ -2139,34 +2140,15 @@ def get_launchd_plist_path() -> Path:
def _detect_venv_dir() -> Path | None:
"""Detect the active virtualenv directory.
Checks ``sys.prefix`` first (works regardless of the directory name),
then ``VIRTUAL_ENV`` env var (covers uv-managed environments where
sys.prefix == sys.base_prefix), then falls back to probing common
directory names under PROJECT_ROOT.
Returns ``None`` when no virtualenv can be found.
Delegates to ``get_venv_path()`` from ``hermes_cli.managed_uv``,
which is the single source of truth for locating the project venv.
Returns ``None`` only when the resolved path doesn't exist on disk
(pre-install state).
"""
# If we're running inside a virtualenv, sys.prefix points to it.
if sys.prefix != sys.base_prefix:
venv = Path(sys.prefix)
if venv.is_dir():
return venv
from hermes_cli.managed_uv import get_venv_path
p = get_venv_path()
return p if p.is_dir() else None
# uv and some other tools set VIRTUAL_ENV without changing sys.prefix.
# This catches `uv run` where sys.prefix == sys.base_prefix but the
# environment IS a venv. (#8620)
_virtual_env = os.environ.get("VIRTUAL_ENV")
if _virtual_env:
venv = Path(_virtual_env)
if venv.is_dir():
return venv
# Fallback: check common virtualenv directory names under the project root.
for candidate in (".venv", "venv"):
venv = PROJECT_ROOT / candidate
if venv.is_dir():
return venv
return None
def get_python_path() -> str:
@@ -2355,7 +2337,8 @@ def generate_systemd_unit(system: bool = False, run_as_user: str | None = None)
python_path = get_python_path()
working_dir = _stable_service_working_dir()
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
path_entries = _build_service_path_dirs()
resolved_node = shutil.which("node")
@@ -3193,7 +3176,8 @@ def generate_launchd_plist() -> str:
# the systemd unit), then capture the user's full shell PATH so every
# user-installed tool (node, ffmpeg, …) is reachable.
detected_venv = _detect_venv_dir()
venv_dir = str(detected_venv) if detected_venv else str(PROJECT_ROOT / "venv")
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_dir = str(detected_venv) if detected_venv else str(_gvp())
# Resolve the directory containing the node binary (e.g. Homebrew, nvm)
# so it's explicitly in PATH even if the user's shell PATH changes later.
priority_dirs = _build_service_path_dirs()
+2 -1
View File
@@ -183,7 +183,8 @@ def _launch_elevated_gateway_command(command: str, extra_args: list[str] | None
if extra_args:
args.extend(extra_args)
params = subprocess.list2cmdline(args)
cwd = str(Path(__file__).resolve().parent.parent)
from hermes_constants import get_hermes_source_root
cwd = str(get_hermes_source_root())
elevated_python = _derive_venv_pythonw(sys.executable)
try:
result = ctypes.windll.shell32.ShellExecuteW(
+81 -132
View File
@@ -320,7 +320,8 @@ def _require_tty(command_name: str) -> None:
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
sys.path.insert(0, str(PROJECT_ROOT))
@@ -4516,10 +4517,10 @@ def _nixos_build_env() -> dict[str, str] | None:
return None
# Tier 1: fast path — hermes venv python3, no nix-shell overhead
for venv_name in ("venv", ".venv"):
venv_python = PROJECT_ROOT / venv_name / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
from hermes_cli.managed_uv import get_venv_path as _gvp
venv_python = _gvp() / "bin" / "python3"
if venv_python.exists():
return {**os.environ, "PYTHON": str(venv_python)}
# Tier 2: nix-shell fallback — resolves the absolute python3 path once.
# Slower (~25 s for the nix-shell eval) but always works, even without
@@ -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)
+209
View File
@@ -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
+2 -1
View File
@@ -129,7 +129,8 @@ def _catalog_root() -> Path:
"""Return the optional-mcps/ directory shipped with this Hermes install."""
# Prefer the env-var override / packaged location; fall back to the repo's
# optional-mcps/ next to the package (source checkout).
return get_optional_mcps_dir(Path(__file__).parent.parent / "optional-mcps")
from hermes_constants import get_hermes_source_root
return get_optional_mcps_dir(get_hermes_source_root() / "optional-mcps")
def _parse_env_spec(raw: Any) -> EnvVarSpec:
+9 -24
View File
@@ -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}")
+2 -1
View File
@@ -151,10 +151,11 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool:
def _has_agent_browser() -> bool:
import shutil
from hermes_constants import get_hermes_source_root
agent_browser_bin = shutil.which("agent-browser")
local_bin = (
Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser"
get_hermes_source_root() / "node_modules" / ".bin" / "agent-browser"
)
return bool(agent_browser_bin or local_bin.exists())
+2 -1
View File
@@ -62,7 +62,8 @@ def get_bundled_plugins_dir() -> Path:
env_override = os.getenv("HERMES_BUNDLED_PLUGINS")
if env_override:
return Path(env_override)
return Path(__file__).resolve().parent.parent / "plugins"
from hermes_constants import get_hermes_source_root
return get_hermes_source_root() / "plugins"
try:
import yaml
+2 -1
View File
@@ -900,7 +900,8 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict
"user_modified": [],
"skipped_opt_out": True,
}
project_root = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
try:
result = subprocess.run(
[sys.executable, "-c",
+1 -1
View File
@@ -21,7 +21,7 @@ logger = logging.getLogger(__name__)
def _print_aiohttp_missing() -> None:
print(
"hermes proxy requires aiohttp. Install one of:\n"
" pip install 'hermes-agent[messaging]'\n"
" uv pip install -e '.[messaging]' # from the hermes-agent checkout\n"
" pip install aiohttp",
file=sys.stderr,
)
+2 -2
View File
@@ -86,7 +86,7 @@ def create_app(adapter: UpstreamAdapter) -> "web.Application":
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
)
app = web.Application()
@@ -253,7 +253,7 @@ async def run_server(
if not AIOHTTP_AVAILABLE:
raise RuntimeError(
"aiohttp is required for `hermes proxy`. Install with: "
"pip install 'hermes-agent[messaging]' or `pip install aiohttp`."
"uv pip install -e '.[messaging]' # from the hermes-agent checkout, or: pip install aiohttp."
)
app = create_app(adapter)
+11 -23
View File
@@ -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:
+2 -1
View File
@@ -9,7 +9,8 @@ import sys
import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions
from pathlib import Path
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
from hermes_cli.auth import AuthError, resolve_provider
from hermes_cli.colors import Colors, color
+13 -75
View File
@@ -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:
+3 -4
View File
@@ -25,9 +25,7 @@ def log_success(msg: str):
def log_warn(msg: str):
print(f"{color('', Colors.YELLOW)} {msg}")
def get_project_root() -> Path:
"""Get the project installation directory."""
return Path(__file__).parent.parent.resolve()
def find_shell_configs() -> list:
@@ -572,7 +570,8 @@ def run_uninstall(args):
- Full uninstall: removes code + ~/.hermes/ (configs, data, logs)
- Keep data: removes code but keeps ~/.hermes/ for future reinstall
"""
project_root = get_project_root()
from hermes_constants import get_hermes_source_root
project_root = get_hermes_source_root()
hermes_home = get_hermes_home()
# Detect named profiles when uninstalling from the default root —
+3 -2
View File
@@ -37,7 +37,8 @@ from typing import Any, Dict, List, Optional, Tuple
import yaml
PROJECT_ROOT = Path(__file__).parent.parent.resolve()
from hermes_constants import get_hermes_source_root
PROJECT_ROOT = get_hermes_source_root()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
@@ -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"
+22
View File
@@ -11,6 +11,28 @@ from contextvars import ContextVar, Token
from pathlib import Path
# ---------------------------------------------------------------------------
# Source-root helper — single source of truth for the repo / install root.
# ---------------------------------------------------------------------------
# ``hermes_constants.py`` lives at the repo root, so its parent *is* the root.
# Every other module that previously did ``Path(__file__).parent.parent`` to
# climb out of sub-packages (hermes_cli/, gateway/, tools/, …) should call
# this instead. Tests can monkeypatch it directly without touching __file__.
def get_hermes_source_root() -> Path:
"""Return the Hermes Agent source / installation root directory.
This is the single authoritative way to locate the project root.
Previously this was scattered across ~20 files as
``Path(__file__).parent.parent.resolve()`` relative to each sub-package.
Returns the directory that contains ``pyproject.toml``, ``hermes_constants.py``,
``hermes_cli/``, ``tools/``, etc.
"""
return Path(__file__).resolve().parent
_profile_fallback_warned: bool = False
_UNSET = object()
_HERMES_HOME_OVERRIDE: ContextVar[str | object] = ContextVar(
+2 -2
View File
@@ -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)
+8
View File
@@ -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.
+5
View File
@@ -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"
+259
View File
@@ -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 817) 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: 23 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).
+5 -10
View File
@@ -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).
+3 -7
View File
@@ -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
+1 -1
View File
@@ -6604,7 +6604,7 @@ def register(ctx) -> None:
check_fn=check_discord_requirements,
is_connected=_is_connected,
required_env=["DISCORD_BOT_TOKEN"],
install_hint="pip install 'hermes-agent[messaging]'",
install_hint="uv pip install -e '.[messaging]' # from the hermes-agent checkout",
# Interactive setup wizard — replaces the central
# hermes_cli/setup.py::_setup_discord function. Same shape as Teams.
setup_fn=interactive_setup,
+1 -1
View File
@@ -3299,7 +3299,7 @@ def register(ctx) -> None:
"GOOGLE_CHAT_SUBSCRIPTION_NAME",
"GOOGLE_CHAT_SERVICE_ACCOUNT_JSON",
],
install_hint="pip install 'hermes-agent[google_chat]'",
install_hint="uv pip install -e '.[google_chat]' # from the hermes-agent checkout",
setup_fn=interactive_setup,
# Env-driven auto-configuration — the core env-populator hook calls
# this during ``_apply_env_overrides`` and seeds
+6 -6
View File
@@ -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
+2 -1
View File
@@ -37,6 +37,7 @@ import sys
from pathlib import Path
from providers.base import OMIT_TEMPERATURE, ProviderProfile # noqa: F401
from hermes_constants import get_hermes_source_root
logger = logging.getLogger(__name__)
@@ -46,7 +47,7 @@ _discovered = False
# Repo-root ``plugins/model-providers/`` — populated at discovery time.
_BUNDLED_PLUGINS_DIR = (
Path(__file__).resolve().parent.parent / "plugins" / "model-providers"
get_hermes_source_root() / "plugins" / "model-providers"
)
+82 -16
View File
@@ -50,7 +50,7 @@ dependencies = [
"tenacity==9.1.4",
"pyyaml==6.0.3",
"ruamel.yaml==0.18.17",
"requests==2.33.0", # CVE-2026-25645
"requests==2.33.0", # CVE-2026-25645
"jinja2==3.1.6",
# Bumped from 2.12.5 to 2.13.4 to pull in pydantic-core 2.46.4.
# pydantic-core 2.41.5 (pulled by 2.12.5) segfaults when the OpenAI SDK's
@@ -82,7 +82,7 @@ dependencies = [
# it out of the lazy-install path that exists only for the heavy matrix deps.
"Markdown==3.10.2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
# urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
# and GHSA-qccp-gfcp-xxvc (header leak across origins).
"urllib3>=2.7.0,<3",
@@ -117,7 +117,7 @@ dependencies = [
[project.optional-dependencies]
# Native Anthropic provider — only needed when provider=anthropic (not via
# OpenRouter or other aggregators).
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
anthropic = ["anthropic==0.87.0"] # CVE-2026-34450, CVE-2026-34452
# Web search backends — each only loaded when the user picks it as their
# search provider (configured via `hermes tools` or config.yaml).
exa = ["exa-py==2.10.2"]
@@ -131,11 +131,34 @@ edge-tts = ["edge-tts==7.2.7"]
modal = ["modal==1.3.4"]
daytona = ["daytona==0.155.0"]
hindsight = ["hindsight-client==0.6.1"]
dev = ["debugpy==1.8.20", "pytest==9.0.2", "pytest-asyncio==1.3.0", "pytest-timeout==2.4.0", "mcp==1.26.0", "starlette==1.0.1", "ty==0.0.21", "ruff==0.15.10", "setuptools==82.0.1"] # starlette: CVE-2026-48710
messaging = ["python-telegram-bot[webhooks]==22.6", "discord.py[voice]==2.7.1", "aiohttp==3.13.4", "brotlicffi==1.2.0.1", "slack-bolt==1.27.0", "slack-sdk==3.40.1", "qrcode==7.4.2"] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = [] # croniter is now a core dependency; this extra kept for back-compat
dev = [
"debugpy==1.8.20",
"pytest==9.0.2",
"pytest-asyncio==1.3.0",
"pytest-timeout==2.4.0",
"mcp==1.26.0",
"starlette==1.0.1",
"ty==0.0.21",
"ruff==0.15.10",
"setuptools==82.0.1",
] # starlette: CVE-2026-48710
messaging = [
"python-telegram-bot[webhooks]==22.6",
"discord.py[voice]==2.7.1",
"aiohttp==3.13.4",
"brotlicffi==1.2.0.1",
"slack-bolt==1.27.0",
"slack-sdk==3.40.1",
"qrcode==7.4.2",
] # aiohttp: CVE-2026-34513/34518/34519/34520/34525
cron = [] # croniter is now a core dependency; this extra kept for back-compat
slack = ["slack-bolt==1.27.0", "slack-sdk==3.40.1", "aiohttp==3.13.4"]
matrix = ["mautrix[encryption]==0.21.0", "aiosqlite==0.22.1", "asyncpg==0.31.0", "aiohttp-socks==0.11.0"]
matrix = [
"mautrix[encryption]==0.21.0",
"aiosqlite==0.22.1",
"asyncpg==0.31.0",
"aiohttp-socks==0.11.0",
]
# WeCom callback-mode adapter — parses untrusted XML POST bodies from
# WeCom-controlled callback endpoints, so we use defusedxml (drop-in
# replacement for stdlib xml.etree.ElementTree) to block billion-laughs
@@ -171,7 +194,7 @@ vision = []
# `request.url` can be bypassed. We pin a patched Starlette directly in every
# extra that exposes a Starlette-backed server surface so pip/uv can't resolve
# a vulnerable pre-1.0.1 transitive. Bump in lockstep with uv.lock.
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
mcp = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
nemo-relay = ["nemo-relay==0.3"]
homeassistant = ["aiohttp==3.13.4"]
sms = ["aiohttp==3.13.4"]
@@ -179,7 +202,7 @@ sms = ["aiohttp==3.13.4"]
# The cua-driver binary itself is installed via `hermes tools` post-setup
# (curl install script); this extra just pins the MCP client used to talk
# to it, which is already provided by the `mcp` extra.
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
computer-use = ["mcp==1.26.0", "starlette==1.0.1"] # starlette: CVE-2026-48710
acp = ["agent-client-protocol==0.9.0"]
# mistral: Voxtral STT + TTS. Pinned to an exact verified-clean version.
# The `mistralai` PyPI project was quarantined 2026-05-12 after the malicious
@@ -213,7 +236,11 @@ termux-all = [
"hermes-agent[sms]",
"hermes-agent[web]",
]
dingtalk = ["dingtalk-stream==0.24.3", "alibabacloud-dingtalk==2.2.42", "qrcode==7.4.2"]
dingtalk = [
"dingtalk-stream==0.24.3",
"alibabacloud-dingtalk==2.2.42",
"qrcode==7.4.2",
]
feishu = ["lark-oapi==1.5.3", "qrcode==7.4.2"]
google = [
# Required by the google-workspace skill (Gmail, Calendar, Drive, Contacts,
@@ -275,7 +302,22 @@ hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_bootstrap", "hermes_constants", "hermes_state", "hermes_time", "hermes_logging", "utils", "mcp_serve"]
py-modules = [
"run_agent",
"model_tools",
"toolsets",
"batch_runner",
"trajectory_compressor",
"toolset_distributions",
"cli",
"hermes_bootstrap",
"hermes_constants",
"hermes_state",
"hermes_time",
"hermes_logging",
"utils",
"mcp_serve",
]
[tool.setuptools.data-files]
# i18n catalogs. locales/ is a bare data directory (no __init__.py), so it is
@@ -301,7 +343,12 @@ locales = ["locales/*.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
hermes_cli = [
"web_dist/**/*",
"tui_dist/**/*",
"scripts/install.sh",
"scripts/install.ps1",
]
gateway = ["assets/**/*"]
plugins = [
"*/dashboard/manifest.json",
@@ -319,13 +366,30 @@ plugins = [
]
[tool.setuptools.packages.find]
include = ["agent", "agent.*", "tools", "tools.*", "hermes_cli", "hermes_cli.*", "gateway", "gateway.*", "tui_gateway", "tui_gateway.*", "cron", "acp_adapter", "plugins", "plugins.*", "providers", "providers.*"]
include = [
"agent",
"agent.*",
"tools",
"tools.*",
"hermes_cli",
"hermes_cli.*",
"gateway",
"gateway.*",
"tui_gateway",
"tui_gateway.*",
"cron",
"acp_adapter",
"plugins",
"plugins.*",
"providers",
"providers.*",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
# This is the fallback inside each per-file pytest subprocess (see
@@ -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
+2 -1
View File
@@ -37,7 +37,8 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
SUPPRESS_MARKER = re.compile(r"#\s*windows-footgun\s*:\s*ok\b", re.IGNORECASE)
+2 -1
View File
@@ -135,7 +135,8 @@ def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
def main() -> int:
fix_mode = "--fix" in sys.argv
repo_root = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
repo_root = get_hermes_source_root()
os.chdir(repo_root)
all_violations = []
+44 -79
View File
@@ -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
View File
@@ -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() {
+4 -10
View File
@@ -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:
+3 -2
View File
@@ -35,7 +35,8 @@ import time
from pathlib import Path
from typing import Any
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
_PROJECT_ROOT = get_hermes_source_root()
sys.path.insert(0, str(_PROJECT_ROOT))
try:
from hermes_constants import get_hermes_home
@@ -46,7 +47,7 @@ except ImportError:
DEFAULT_TUI_DIR = Path(
os.environ.get("HERMES_TUI_DIR")
or str(Path(__file__).resolve().parent.parent / "ui-tui")
or str(_PROJECT_ROOT / "ui-tui")
)
DEFAULT_LOG = Path(os.environ.get("HERMES_PERF_LOG", str(get_hermes_home() / "perf.log")))
DEFAULT_STATE_DB = get_hermes_home() / "state.db"
+2 -1
View File
@@ -30,7 +30,8 @@ from collections import defaultdict
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
VERSION_FILE = REPO_ROOT / "hermes_cli" / "__init__.py"
PYPROJECT_FILE = REPO_ROOT / "pyproject.toml"
+2 -1
View File
@@ -739,7 +739,8 @@ def main() -> int:
print(f"error: --slice must be I/N (e.g. 1/4), got: {slice_raw!r}", file=sys.stderr)
sys.exit(2)
repo_root = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
repo_root = get_hermes_source_root()
# Resolve discovery roots: positional path args override --paths if any
# were supplied, otherwise --paths (which itself defaults to 'tests').
+2 -1
View File
@@ -360,7 +360,8 @@ def main(
print(f" Seed: {seed}")
# Setup paths
base_dir = Path(__file__).parent.parent
from hermes_constants import get_hermes_source_root
base_dir = get_hermes_source_root()
sampled_dir = base_dir / "data" / f"{output_name}_raw"
compressed_dir = base_dir / "data" / f"{output_name}_batches"
final_output = base_dir / "data" / f"{output_name}.jsonl"
@@ -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
+4 -2
View File
@@ -203,7 +203,8 @@ class TestGatewayBridgeCodeParity:
in source. Assert the dynamic shape and the canonical built-in keys
bridged set instead.
"""
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
from hermes_constants import get_hermes_source_root
gateway_path = get_hermes_source_root() / "gateway" / "run.py"
# Pin encoding to UTF-8: source files in this repo are UTF-8, but
# Path.read_text() defaults to the system locale — which is cp1252
# on most Western Windows installs and crashes as soon as the file
@@ -224,7 +225,8 @@ class TestGatewayBridgeCodeParity:
def test_gateway_no_compression_env_bridge(self):
"""Gateway should NOT bridge compression config to env vars (config-only)."""
gateway_path = Path(__file__).parent.parent.parent / "gateway" / "run.py"
from hermes_constants import get_hermes_source_root
gateway_path = get_hermes_source_root() / "gateway" / "run.py"
# See note in test_gateway_has_auxiliary_bridge — pin UTF-8 so the
# test runs on Windows where the default locale is cp1252.
content = gateway_path.read_text(encoding="utf-8")
+2 -1
View File
@@ -258,7 +258,8 @@ class TestPackaging:
import tomllib
from pathlib import Path
content = (Path(__file__).parent.parent.parent / "pyproject.toml").read_text()
from hermes_constants import get_hermes_source_root
content = (get_hermes_source_root() / "pyproject.toml").read_text()
return tomllib.loads(content)["project"]["optional-dependencies"]
def test_bedrock_extra_exists(self):
@@ -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"
+2 -1
View File
@@ -123,7 +123,8 @@ class TestNoMoreBareDeleteSites:
from pathlib import Path
import re
gateway_run = (Path(__file__).parent.parent.parent / "gateway" / "run.py").read_text()
from hermes_constants import get_hermes_source_root
gateway_run = (get_hermes_source_root() / "gateway" / "run.py").read_text()
# Match `del self._running_agents[...]` that is NOT inside a
# triple-quoted docstring. We scan non-docstring lines only.
lines = gateway_run.splitlines()
+17 -18
View File
@@ -71,42 +71,41 @@ def _patch_managed_uv(request):
class TestCmdUpdatePip:
"""Regression tests for pip-install update flows."""
"""Tests for pip-install update flows."""
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_exports_virtualenv_from_sys_prefix(
def test_update_pip_uv_tool_upgrade(
self, mock_run, _mock_which, mock_args, monkeypatch
):
"""uv-tool installs still use uv tool upgrade."""
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/tmp/hermes-launcher-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
with patch("hermes_cli.config.is_uv_tool_install", return_value=True):
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/tmp/hermes-launcher-venv"
assert mock_run.call_args.args[0] == ["/usr/bin/uv", "tool", "upgrade", "hermes-agent"]
@patch("shutil.which", return_value="/usr/bin/uv")
@patch("subprocess.run")
def test_update_pip_does_not_export_virtualenv_for_system_python(
self, mock_run, _mock_which, mock_args, monkeypatch
def test_update_pip_plain_pypi_install_exits_with_error(
self, _mock_which, mock_args, monkeypatch, capsys
):
"""Plain pip install (not uv-tool, not pipx) now exits with a helpful error."""
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "prefix", "/tmp/some-venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
hm._cmd_update_pip(mock_args)
with patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
pytest.raises(SystemExit) as exc_info:
hm._cmd_update_pip(mock_args)
assert mock_run.call_count == 1
assert "env" not in mock_run.call_args.kwargs
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "no longer supported" in out
assert "hermes-agent.nousresearch.com/install.sh" in out
class TestCmdUpdateBranchFallback:
+2 -1
View File
@@ -24,7 +24,8 @@ from unittest.mock import patch, MagicMock
# Path to the source files under test
_SRC_ROOT = Path(__file__).parent.parent.parent / "hermes_cli"
from hermes_constants import get_hermes_source_root
_SRC_ROOT = get_hermes_source_root() / "hermes_cli"
class TestInitPairClampingBehavior:
+15 -9
View File
@@ -227,16 +227,19 @@ def test_check_gateway_service_linger_warns_when_disabled(monkeypatch, tmp_path,
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
monkeypatch.setattr(gateway_cli, "get_systemd_linger_status", lambda: (False, ""))
issues = []
doctor._check_gateway_service_linger(issues)
from hermes_cli.doctor._registry import DiagnosticReport
from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger
report = DiagnosticReport()
report.section("Gateway Service")
check_gateway_service_linger(report)
out = capsys.readouterr().out
assert "Gateway Service" in out
assert "Systemd linger disabled" in out
assert "loginctl enable-linger" in out
assert issues == [
"Enable linger for the gateway user service: sudo loginctl enable-linger $USER"
]
issues = [i.text for i in report._issues]
assert any("linger" in i.lower() for i in issues)
def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypatch, tmp_path, capsys):
@@ -245,12 +248,16 @@ def test_check_gateway_service_linger_skips_when_service_not_installed(monkeypat
monkeypatch.setattr(gateway_cli, "is_linux", lambda: True)
monkeypatch.setattr(gateway_cli, "get_systemd_unit_path", lambda: unit_path)
issues = []
doctor._check_gateway_service_linger(issues)
from hermes_cli.doctor._registry import DiagnosticReport
from hermes_cli.doctor.checks.gateway_service import check_gateway_service_linger
report = DiagnosticReport()
report.section("Gateway Service")
check_gateway_service_linger(report)
out = capsys.readouterr().out
assert out == ""
assert issues == []
assert report._issues == []
# ── Memory provider section (doctor should only check the *active* provider) ──
@@ -484,7 +491,6 @@ def test_run_doctor_flags_missing_credentials_for_active_openrouter_provider(mon
out = buf.getvalue()
assert "model.provider 'openrouter' is set but no API key is configured" in out
assert "No credentials found for provider 'openrouter'." in out
@pytest.mark.parametrize(
+2 -2
View File
@@ -240,7 +240,7 @@ def test_run_uninstall_yes_keep_data_is_non_interactive(tmp_path, monkeypatch):
# Stub every destructive external so the test only exercises the control
# flow + the real GUI sweep (which is safe inside tmp_path).
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
@@ -274,7 +274,7 @@ def test_run_uninstall_yes_full_wipes_home(tmp_path, monkeypatch):
fake_code.mkdir()
monkeypatch.setattr(uninstall, "get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(uninstall, "get_project_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "get_hermes_source_root", lambda: fake_code)
monkeypatch.setattr(uninstall, "uninstall_gateway_service", lambda: False)
monkeypatch.setattr(uninstall, "remove_path_from_shell_configs", lambda: [])
monkeypatch.setattr(uninstall, "remove_wrapper_script", lambda: [])
@@ -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"
+43 -83
View File
@@ -140,28 +140,25 @@ class TestRecommendedUpdateCommandForUvTool:
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv tool upgrade hermes-agent"
def test_uv_pip_install_keeps_legacy_recommendation(self):
"""Existing behavior: uv is on PATH but Hermes is a regular pip install."""
from hermes_cli import config
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "uv pip install --upgrade hermes-agent"
def test_no_uv_falls_back_to_plain_pip(self):
def test_uv_pip_install_redirects_to_installer(self):
"""Non-uv-tool, non-pipx pip install → redirect to official installer."""
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert cmd == "pip install --upgrade hermes-agent"
assert "hermes-agent.nousresearch.com/install.sh" in cmd
def test_no_uv_also_redirects_to_installer(self):
from hermes_cli import config
with patch("shutil.which", return_value=None), \
patch.object(config, "is_uv_tool_install", return_value=False):
cmd = config.recommended_update_command_for_method("pip")
assert "hermes-agent.nousresearch.com/install.sh" in cmd
def test_recommendation_does_not_spawn_subprocess(self):
"""Computing the recommendation string must be cheap — no ``uv tool list``
spawn. Copilot review on PR #29703 flagged the prior subprocess hop
as adding overhead and a multi-second timeout window for what is
purely a display string."""
"""Computing the recommendation string must be cheap — no subprocess spawn."""
from hermes_cli import config
with patch.object(config.sys, "prefix", "/some/unrelated/venv"), \
@@ -170,7 +167,7 @@ class TestRecommendedUpdateCommandForUvTool:
patch("subprocess.run") as mock_run:
cmd = config.recommended_update_command_for_method("pip")
mock_run.assert_not_called()
assert cmd == "uv pip install --upgrade hermes-agent"
assert "hermes-agent.nousresearch.com/install.sh" in cmd
# ---------------------------------------------------------------------------
@@ -191,35 +188,29 @@ class TestCmdUpdatePipUsesUvTool:
assert mock_run.call_args[0][0] == ["/usr/local/bin/uv", "tool", "upgrade", "hermes-agent"]
@patch("subprocess.run")
def test_runs_uv_pip_install_when_not_uv_tool(self, mock_run):
"""Existing behavior preserved when uv is present but Hermes isn't a tool install."""
def test_runs_uv_pip_install_when_not_uv_tool(self, capsys):
"""Non-uv-tool with uv present → PyPI path, now exits with error."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["uv"], 0, stdout="", stderr="")
with patch("shutil.which", return_value="/usr/local/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/local/bin/uv",
"pip",
"install",
"--upgrade",
"hermes-agent",
]
assert exc_info.value.code == 1
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
@patch("subprocess.run")
def test_falls_back_to_pip_when_no_uv(self, mock_run):
def test_falls_back_to_pip_when_no_uv(self, capsys):
"""No uv, not uv-tool → PyPI path, now exits with error."""
from hermes_cli.main import _cmd_update_pip
mock_run.return_value = subprocess.CompletedProcess(["pip"], 0, stdout="", stderr="")
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
pytest.raises(SystemExit) as exc_info:
_cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert cmd[1:] == ["-m", "pip", "install", "--upgrade", "hermes-agent"]
assert exc_info.value.code == 1
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
@patch("subprocess.run")
def test_exits_nonzero_on_subprocess_failure(self, mock_run):
@@ -255,12 +246,7 @@ class TestCmdUpdatePipUsesUvTool:
class TestCmdUpdatePipInstallLayouts:
"""The uv pip path must adapt to where the running interpreter lives:
- inside a venv (launcher shim) -> export VIRTUAL_ENV, no ``--system``
- bare pip outside any venv -> add ``--system``, no overlay
- pipx-managed -> ``pipx upgrade``
"""
"""Update path tests for various install layouts."""
@patch("subprocess.run")
def test_pipx_managed_uses_pipx_upgrade(self, mock_run, monkeypatch):
@@ -278,65 +264,39 @@ class TestCmdUpdatePipInstallLayouts:
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == ["/usr/bin/pipx", "upgrade", "hermes-agent"]
# pipx upgrade ignores VIRTUAL_ENV; we must not set it.
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_pipx_layout_without_pipx_binary_treated_as_venv(
self, mock_run, monkeypatch
def test_pipx_layout_without_pipx_binary_exits_with_error(
self, monkeypatch, capsys
):
"""pipx layout detected but no pipx binary → unsupported, show installer."""
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.local/pipx/venvs/hermes-agent")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
# pipx layout detected via prefix, but pipx binary missing on PATH.
def _which(name):
return "/usr/bin/uv" if name == "uv" else None
with patch("shutil.which", side_effect=_which), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
with patch("shutil.which", return_value=None), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
pytest.raises(SystemExit) as exc_info:
hm._cmd_update_pip(SimpleNamespace())
# prefix != base_prefix, so this is treated as a venv -> overlay, no --system.
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent",
]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"].endswith("hermes-agent")
assert exc_info.value.code == 1
assert "hermes-agent.nousresearch.com/install.sh" in capsys.readouterr().out
@patch("subprocess.run")
def test_bare_pip_outside_venv_adds_system(self, mock_run, monkeypatch):
def test_plain_venv_pip_install_exits_with_error(self, monkeypatch, capsys):
"""Non-uv-tool, non-pipx regular venv → PyPI path, now unsupported."""
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
# No venv: prefix == base_prefix.
monkeypatch.setattr(hm.sys, "prefix", "/usr")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
hm._cmd_update_pip(SimpleNamespace())
assert mock_run.call_args[0][0] == [
"/usr/bin/uv", "pip", "install", "--system", "--upgrade", "hermes-agent",
]
assert "env" not in mock_run.call_args.kwargs
@patch("subprocess.run")
def test_venv_exports_virtualenv_and_omits_system(self, mock_run, monkeypatch):
from hermes_cli import main as hm
mock_run.return_value = subprocess.CompletedProcess([], 0, stdout="", stderr="")
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
monkeypatch.setattr(hm.sys, "prefix", "/home/u/.hermes/hermes-agent/venv")
monkeypatch.setattr(hm.sys, "base_prefix", "/usr")
with patch("shutil.which", return_value="/usr/bin/uv"), \
patch("hermes_cli.config.is_uv_tool_install", return_value=False):
patch("hermes_cli.config.is_uv_tool_install", return_value=False), \
pytest.raises(SystemExit) as exc_info:
hm._cmd_update_pip(SimpleNamespace())
cmd = mock_run.call_args[0][0]
assert "--system" not in cmd
assert cmd == ["/usr/bin/uv", "pip", "install", "--upgrade", "hermes-agent"]
assert mock_run.call_args.kwargs["env"]["VIRTUAL_ENV"] == "/home/u/.hermes/hermes-agent/venv"
assert exc_info.value.code == 1
out = capsys.readouterr().out
assert "no longer supported" in out
assert "hermes-agent.nousresearch.com/install.sh" in out
+8 -11
View File
@@ -31,6 +31,8 @@ from unittest.mock import MagicMock
import pytest
from hermes_constants import get_hermes_source_root as _repo_root
# ---------------------------------------------------------------------------
# OpenAI SDK construction preserves the callable
@@ -245,8 +247,7 @@ class TestBatchRunnerCallableHandling:
"""Pin the predicate string in batch_runner so refactors that
change it are caught here. Reading the source rather than
importing avoids spinning up the full BatchRunner."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "batch_runner.py").read_text()
assert "callable(self.api_key) and not isinstance(self.api_key, str)" in src, (
"BatchRunner.api_key callable check changed — update test or "
@@ -278,7 +279,7 @@ class TestCliEnsureRuntimeCredentialsCallable:
# ``_ensure_runtime_credentials`` was extracted from cli.py into the
# ``CLIAgentSetupMixin`` (god-file decomposition Phase 4). Read the
# module the method actually lives in now.
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "hermes_cli" / "cli_agent_setup_mixin.py").read_text()
# The fix introduces ``_is_callable_provider`` which gates the
# string-only check so callable token providers survive.
@@ -305,8 +306,7 @@ class TestInlinedDisplayMasks:
client init paths must guard their banner prints with
``is_token_provider`` so a callable Entra ID provider doesn't
crash ``len(api_key)``."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "agent" / "agent_init.py").read_text()
assert src.count("is_token_provider(") >= 2, (
"agent/agent_init.py must guard BOTH masked-banner paths "
@@ -325,8 +325,7 @@ class TestInlinedDisplayMasks:
callable Entra ID providers. The inlined version uses
``is_token_provider`` and prints the same static label as the
run_agent banners."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "cli.py").read_text()
assert "is_token_provider(self.api_key)" in src, (
"cli.HermesCLI.show_config must guard self.api_key via "
@@ -346,8 +345,7 @@ class TestInlinedDisplayMasks:
defensively the helper must also accept a callable directly
and return the placeholder rather than crashing on
``len(callable)``."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "run_agent.py").read_text()
# The function now starts with a callable check.
assert (
@@ -365,8 +363,7 @@ class TestInlinedDisplayMasks:
was extracted after this feature was first written). It used
to do ``key[:12]`` on ``self._anthropic_api_key``. For Entra ID +
Anthropic-style mode that's a callable; slicing crashes."""
from pathlib import Path
src = (Path(__file__).resolve().parent.parent.parent
src = (_repo_root()
/ "agent" / "conversation_loop.py").read_text()
# The Anthropic 401 block now branches on is_token_provider
# before slicing the key.
+2 -1
View File
@@ -21,7 +21,8 @@ import pytest
import yaml
# Ensure the repo root is importable when running via `pytest tests/...`.
_REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
_REPO_ROOT = get_hermes_source_root()
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
@@ -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():
+2 -1
View File
@@ -26,7 +26,8 @@ from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
ELECTRON_DIR = REPO_ROOT / "apps" / "desktop" / "electron"
MAIN_PLIST = ELECTRON_DIR / "entitlements.mac.plist"
INHERIT_PLIST = ELECTRON_DIR / "entitlements.mac.inherit.plist"
+2 -1
View File
@@ -3,7 +3,8 @@
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh"
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
+2 -1
View File
@@ -12,7 +12,8 @@ from pathlib import Path
def _dockerfile_text() -> str:
return (Path(__file__).parent.parent / "Dockerfile").read_text(encoding="utf-8")
from hermes_constants import get_hermes_source_root
return (get_hermes_source_root() / "Dockerfile").read_text(encoding="utf-8")
def test_tini_compat_symlink_present():
+2 -1
View File
@@ -3,7 +3,8 @@ from pathlib import Path
import importlib.util
# Load the hyphenated script name dynamically
repo_root = Path(__file__).parent.parent
from hermes_constants import get_hermes_source_root
repo_root = get_hermes_source_root()
script_path = repo_root / "optional-skills" / "security" / "oss-forensics" / "scripts" / "evidence-store.py"
spec = importlib.util.spec_from_file_location("evidence_store", str(script_path))
+2 -1
View File
@@ -21,7 +21,8 @@ from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
+2 -1
View File
@@ -8,7 +8,8 @@ unsupported distribution.
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
from hermes_constants import get_hermes_source_root
REPO_ROOT = get_hermes_source_root()
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"

Some files were not shown because too many files have changed in this diff Show More