refactor(hermes_cli): doctor status tables (_OPENROUTER_STATUS, _PACKAGES, _KEYED_PROBES), _require helper, compact docstrings

This commit is contained in:
Teknium
2026-09-02 20:51:36 -07:00
parent 8b19d33157
commit 2684412ed5
8 changed files with 241 additions and 373 deletions

View File

@@ -124,7 +124,7 @@ def _check_api_connectivity(should_fix: bool) -> Finding:
print("\r" + " " * 70 + "\r", end="")
for r in results:
for glyph, label, detail in r.lines:
print(f" {glyph} {label} {detail}" if detail else f" {glyph} {label}")
print(f" {glyph} {label}" + (f" {detail}" if detail else ""))
if r.issues and not _has_healthy_oauth_fallback_for_apikey_provider(r.label):
f.issues.extend(r.issues)
return f

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
import os
import shutil
from hermes_cli.doctor_report import (
Finding, _fail_and_issue, _section, check_fail, check_info, check_ok, check_warn, doctor_check,
Finding, _fail_and_issue, _section, check_bool, check_fail, check_info, check_ok, check_warn, doctor_check,
)
@@ -46,10 +46,7 @@ _DEPRECATED_ENV_VARS: tuple[tuple[str, str], ...] = (
def collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, str]]:
"""Return ``(legacy_path, replacement)`` for deprecated keys present in the on-disk YAML.
Empty containers still count — presence of the legacy key is the signal to migrate.
"""
"""``(legacy_path, replacement)`` for deprecated keys in the on-disk YAML (empty containers still count)."""
if not isinstance(raw_config, dict):
return []
return [
@@ -60,11 +57,8 @@ def collect_deprecated_config_keys(raw_config: dict | None) -> list[tuple[str, s
def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:
"""Return ``(legacy_env, replacement)`` for deprecated vars present in *env_map*.
*env_map* should come from the on-disk ``.env`` (e.g. ``load_env()``), not
``os.environ``, so bridged runtime vars do not trigger false positives.
"""
"""``(legacy_env, replacement)`` for deprecated vars in *env_map* (the on-disk ``.env``, not ``os.environ``,
so bridged runtime vars do not false-positive)."""
if not isinstance(env_map, dict):
return []
return [(name, replacement) for name, replacement in _DEPRECATED_ENV_VARS
@@ -101,11 +95,8 @@ def collect_relay_plugin_cutover_findings(raw_config: dict | None, env_map: dict
def report_deprecated_config_and_env(raw_config: dict | None = None, env_map: dict | None = None) -> list[tuple[str, str]]:
"""Emit non-failing doctor warnings for deprecated config keys and env vars.
Returns the ``(legacy, replacement)`` findings reported (empty when nothing deprecated
is present). Does not mutate config/env and does not append to the blocking ``issues`` list.
"""
"""Emit non-failing doctor warnings for deprecated config keys and env vars; returns the findings reported.
Does not mutate config/env and does not append to the blocking ``issues`` list."""
deprecated = collect_deprecated_config_keys(raw_config) + collect_deprecated_env_vars(env_map)
relay_cutover = collect_relay_plugin_cutover_findings(raw_config, env_map)
findings = deprecated + relay_cutover
@@ -123,11 +114,8 @@ def report_deprecated_config_and_env(raw_config: dict | None = None, env_map: di
def managed_scope_check() -> None:
"""Report the active managed scope (resolved dir + pinned key counts); silent when none.
A managed dir resolved from the HERMES_MANAGED_DIR override (rather than the system default) is
surfaced too — a redirected scope is the documented foot-gun (docs/design/managed-scope.md §7).
"""
"""Report the active managed scope (resolved dir + pinned key counts); silent when none. A HERMES_MANAGED_DIR
override is surfaced too — a redirected scope is the documented foot-gun (docs/design/managed-scope.md §7)."""
try:
from hermes_cli import managed_scope
managed_dir = managed_scope.get_managed_dir()
@@ -178,10 +166,7 @@ def _check_env_file(should_fix: bool) -> Finding:
content = env_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
content = env_path.read_text(encoding="latin-1")
if _has_provider_env_config(content):
check_ok("API key or custom endpoint configured")
else:
check_warn(f"No API key found in {_DHH}/.env")
if not check_bool(_has_provider_env_config(content), "API key or custom endpoint configured", f"No API key found in {_DHH}/.env"):
f.issues.append("Run 'hermes setup' to configure API keys")
elif (PROJECT_ROOT / '.env').exists(): # project root as fallback
check_ok(".env file exists (in project directory)")
@@ -205,20 +190,16 @@ def _check_env_file(should_fix: bool) -> Finding:
def _known_provider_ids(cfg: dict) -> tuple[set, list, object, object, object]:
"""Return (known ids, custom providers, resolve_auth, normalize, resolve_full).
Registry lookups are best-effort: any import failure leaves the matching resolver as None so
validation degrades to "unavailable" rather than crashing doctor.
"""
"""Return (known ids, custom providers, resolve_auth, normalize, resolve_full); any import failure leaves
the matching resolver as None so validation degrades to "unavailable" rather than crashing doctor."""
known: set = set()
resolve_auth = normalize = resolve_full = None
resolve_auth = normalize = resolve_full = aliases = None
custom_providers: list = []
try:
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider as resolve_auth
known = set(PROVIDER_REGISTRY.keys()) | {"openrouter", "custom", "auto", "moa"}
except Exception:
pass
custom_providers: list = []
aliases = None
try:
from hermes_cli.config import get_compatible_custom_providers
from hermes_cli.providers import (
@@ -236,11 +217,8 @@ def _known_provider_ids(cfg: dict) -> tuple[set, list, object, object, object]:
user_providers = cfg.get("providers")
if isinstance(user_providers, dict):
from hermes_cli.config import is_provider_enabled
known.update(
str(name).strip().lower()
for name, prov_cfg in user_providers.items()
if str(name).strip() and is_provider_enabled(prov_cfg)
)
known.update(str(name).strip().lower() for name, prov_cfg in user_providers.items()
if str(name).strip() and is_provider_enabled(prov_cfg))
if aliases is not None:
for entry in custom_providers:
name = str(entry.get("name") or "").strip() if isinstance(entry, dict) else ""
@@ -258,9 +236,8 @@ _VENDOR_SLUG_PROVIDERS = {
def _provider_has_credentials(runtime_provider: str) -> bool:
"""Only API-key providers in PROVIDER_REGISTRY are checked — OAuth/SDK/custom providers have their
own env-var checks elsewhere in doctor, and get_auth_status() returns a bare {logged_in: False}
for anything it doesn't dispatch, which would false-positive."""
"""Only API-key providers in PROVIDER_REGISTRY are checked — OAuth/SDK/custom providers have their own
checks elsewhere, and get_auth_status() returns a bare {logged_in: False} for anything it doesn't dispatch."""
if runtime_provider == "openrouter":
from hermes_cli.config import get_env_value
@@ -308,18 +285,12 @@ def _validate_model_config(config_path, issues: list) -> None:
if catalog_provider is not None:
accept.add(catalog_provider)
if provider and provider != "auto" and (
catalog_provider is None or (known_providers and not (accept & valid_provider_ids))
):
if provider and provider != "auto" and (catalog_provider is None or (known_providers and not (accept & valid_provider_ids))):
known_list = ", ".join(sorted(known_providers)) if known_providers else "(unavailable)"
_fail_and_issue(
f"model.provider '{provider_raw}' is not a recognised provider",
f"(known: {known_list})",
(
f"model.provider '{provider_raw}' is unknown. "
f"Valid providers: {known_list}. "
f"Fix: run 'hermes config set model.provider <valid_provider>'"
),
f"model.provider '{provider_raw}' is not a recognised provider", f"(known: {known_list})",
f"model.provider '{provider_raw}' is unknown. Valid providers: {known_list}. "
f"Fix: run 'hermes config set model.provider <valid_provider>'",
issues,
)
@@ -342,11 +313,9 @@ def _validate_model_config(config_path, issues: list) -> None:
_fail_and_issue(
f"model.provider '{runtime_provider}' is set but no API key is configured",
"(check ~/.hermes/.env or run 'hermes setup')",
(
f"No credentials found for provider '{runtime_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'"
),
f"No credentials found for provider '{runtime_provider}'. "
f"Run 'hermes setup' or set the provider's API key in {_DHH}/.env, "
f"or switch providers with 'hermes config set model.provider <name>'",
issues,
)
except Exception:
@@ -385,10 +354,9 @@ def _check_config_file(should_fix: bool) -> Finding:
def _drift_config_version(f: Finding, should_fix: bool, config_path) -> None:
from hermes_cli.config import check_config_version, migrate_config
current_ver, latest_ver = check_config_version()
if current_ver >= latest_ver:
check_ok(f"Config version up to date (v{current_ver})")
if check_bool(current_ver >= latest_ver, f"Config version up to date (v{current_ver})",
(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)")):
return
check_warn(f"Config version outdated (v{current_ver} → v{latest_ver})", "(new settings available)")
if not should_fix:
f.issues.append("Run 'hermes doctor --fix' or 'hermes setup' to migrate config")
return
@@ -412,8 +380,7 @@ def _drift_stale_root_keys(f: Finding, should_fix: bool, config_path) -> None:
if not should_fix:
f.issues.append("Stale root-level provider/base_url in config.yaml — run 'hermes doctor --fix'")
return
# Coerce scalar/None ``model:`` into a dict before mutation — setdefault
# would hand back an existing scalar and item-assignment would TypeError.
# Coerce scalar/None ``model:`` into a dict before mutation (setdefault would hand back a scalar).
raw_model = raw_config.get("model")
if isinstance(raw_model, dict):
model_section = raw_model
@@ -432,10 +399,9 @@ def _drift_stale_root_keys(f: Finding, should_fix: bool, config_path) -> None:
def _drift_max_iterations_ghost(f: Finding, should_fix: bool, config_path) -> None:
"""A stale HERMES_MAX_ITERATIONS in .env shadows agent.max_turns in config.yaml.
The setup wizard used to dual-write the budget to both stores. The gateway bridge normally derives
HERMES_MAX_ITERATIONS from agent.max_turns, but if that bridge bails on an earlier config-parse
error the .env value silently wins. Read the .env FILE (load_env), not get_env_value/os.environ,
which the bridge may have overridden already.
The setup wizard used to dual-write the budget. The gateway bridge derives HERMES_MAX_ITERATIONS from
agent.max_turns, but if it bails on an earlier config-parse error the .env value silently wins. Read the
.env FILE (load_env), not get_env_value/os.environ, which the bridge may have overridden already.
"""
from hermes_cli.doctor import _DHH
from hermes_cli.config import load_env, read_user_config_raw, remove_env_value
@@ -447,29 +413,20 @@ def _drift_max_iterations_ghost(f: Finding, should_fix: bool, config_path) -> No
env_ghost = load_env().get("HERMES_MAX_ITERATIONS")
if cfg_max_turns is None or env_ghost is None or str(cfg_max_turns).strip() == str(env_ghost).strip():
return
check_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)",
)
check_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)")
if not should_fix:
f.issues.append("Stale HERMES_MAX_ITERATIONS in .env shadows config.yaml — run 'hermes doctor --fix'")
elif remove_env_value("HERMES_MAX_ITERATIONS"):
check_ok(
"Removed stale HERMES_MAX_ITERATIONS from .env "
f"(config.yaml agent.max_turns={cfg_max_turns} is now authoritative)"
)
check_ok(f"Removed stale HERMES_MAX_ITERATIONS from .env (config.yaml agent.max_turns={cfg_max_turns} is now authoritative)")
f.fixed += 1
else:
check_warn("Could not remove HERMES_MAX_ITERATIONS from .env")
f.manual_issues.append(
"Manually delete the HERMES_MAX_ITERATIONS line from "
f"{_DHH}/.env — config.yaml agent.max_turns is authoritative."
)
f.manual_issues.append(f"Manually delete the HERMES_MAX_ITERATIONS line from {_DHH}/.env — config.yaml agent.max_turns is authoritative.")
def _drift_deprecations(f: Finding, should_fix: bool, config_path) -> None:
"""Warn-only deprecation sweep over the raw file + on-disk .env (bridged
process env like TERMINAL_CWD would false-positive)."""
"""Warn-only deprecation sweep over the raw file + on-disk .env (process env would false-positive)."""
from hermes_cli.config import load_env, read_user_config_raw
raw = read_user_config_raw(config_path) if config_path is not None else {}
try:

View File

@@ -1,13 +1,13 @@
"""API connectivity probes for ``hermes doctor`` (split out of ``doctor.py``).
Every probe is a pure function: takes its inputs, makes one HTTP/SDK call and
returns a ``ProbeResult`` carrying the row(s) to print and issue strings to
append. No printing inside workers — the caller prints in submission order.
Every probe is a pure function: one HTTP/SDK call returning a ``ProbeResult`` with the row(s) to
print and issue strings to append. No printing inside workers — the caller prints in submission order.
"""
from __future__ import annotations
import concurrent.futures
import functools
import os
import sys
from typing import NamedTuple
@@ -40,9 +40,8 @@ def _skip(name: str) -> ProbeResult:
def _has_healthy_oauth_fallback_for_apikey_provider(provider_label: str) -> bool:
"""True when a failed direct API-key probe is non-blocking because the same
provider family's OAuth runtime path is already healthy: the failed row is
still shown, but not promoted into the final blocking summary."""
"""True when a failed direct API-key probe is non-blocking because the same provider family's OAuth
runtime path is already healthy: the failed row is still shown, but not promoted into the summary."""
normalized = (provider_label or "").strip().lower()
getter = {"minimax": "get_minimax_oauth_auth_status", "xai": "get_xai_oauth_auth_status"}.get(normalized)
if not getter:
@@ -84,18 +83,14 @@ def _build_apikey_providers_list() -> list:
("OpenCode Go", ("OPENCODE_GO_API_KEY",), None, "OPENCODE_GO_BASE_URL", False),
]
_known_names = {t[0] for t in _static}
# Canonical profile names of the static rows, so profiles without a
# display_name don't duplicate providers already listed above.
_known_canonical = {
"zai", "kimi-coding", "stepfun", "kimi-coding-cn", "arcee", "gmi", "deepseek",
"huggingface", "nvidia", "alibaba", "minimax", "minimax-cn", "ai-gateway",
"kilocode", "opencode-zen", "opencode-go",
}
# Providers with a dedicated health check (custom headers/auth). Skip their
# pluggable profiles so the generic Bearer-auth loop doesn't run a duplicate,
# broken check (e.g. Anthropic native API requires x-api-key, not Bearer).
# Providers with a dedicated health check (custom headers/auth): skip their pluggable profiles so
# the generic Bearer loop doesn't run a duplicate, broken check (Anthropic needs x-api-key).
_dedicated_canonical = {"anthropic", "openrouter", "bedrock"}
_known_canonical.update(_dedicated_canonical)
# Canonical profile names of the static rows, so profiles without a display_name don't duplicate.
_known_canonical = {
"zai", "kimi-coding", "stepfun", "kimi-coding-cn", "arcee", "gmi", "deepseek", "huggingface", "nvidia",
"alibaba", "minimax", "minimax-cn", "ai-gateway", "kilocode", "opencode-zen", "opencode-go",
} | _dedicated_canonical
try:
from providers import list_providers
from providers.base import ProviderProfile as _PP
@@ -126,6 +121,18 @@ def _build_apikey_providers_list() -> list:
return _static
# HTTP status -> (detail, issue) for the OpenRouter probe; anything else is a generic HTTP failure.
_OPENROUTER_STATUS = {
401: ("(invalid API key)", "Check OPENROUTER_API_KEY in .env"),
402: ("(out of credits — payment required)",
"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"),
429: ("(rate limited)", "OpenRouter rate limit hit — consider switching to a different provider or waiting"),
}
def _probe_openrouter() -> ProbeResult:
name = "OpenRouter API"
key = os.getenv("OPENROUTER_API_KEY")
@@ -138,18 +145,8 @@ def _probe_openrouter() -> ProbeResult:
return _row(name, "fail", f"({e})", ["Check network connectivity"])
if r.status_code == 200:
return _row(name, "ok")
if r.status_code == 401:
return _row(name, "fail", "(invalid API key)", ["Check OPENROUTER_API_KEY in .env"])
if r.status_code == 402:
return _row(name, "fail", "(out of credits — payment required)", [
"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 _row(name, "fail", "(rate limited)", [
"OpenRouter rate limit hit — consider switching to a different provider or waiting"])
return _row(name, "fail", f"(HTTP {r.status_code})")
detail, issue = _OPENROUTER_STATUS.get(r.status_code, (f"(HTTP {r.status_code})", None))
return _row(name, "fail", detail, [issue] if issue else None)
def _probe_anthropic() -> ProbeResult:
@@ -180,9 +177,7 @@ def _probe_anthropic() -> ProbeResult:
return _row(name, "warn", f"({e})")
if r.status_code == 200:
return _row(name, "ok")
if r.status_code == 401:
return _row(name, "fail", "(invalid API key)")
return _row(name, "warn", "(couldn't verify)")
return _row(name, "fail", "(invalid API key)") if r.status_code == 401 else _row(name, "warn", "(couldn't verify)")
def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_health_check) -> ProbeResult:
@@ -195,13 +190,11 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_heal
try:
import httpx
base = os.getenv(base_env, "") if base_env else ""
# Auto-detect Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1
# (OpenAI-compat surface, which exposes /models for health check).
# Kimi Code keys (sk-kimi-) → api.kimi.com/coding/v1 (OpenAI-compat surface exposing /models).
if not base and key.startswith("sk-kimi-"):
base = "https://api.kimi.com/coding/v1"
# Anthropic-compat endpoints (/anthropic, api.kimi.com/coding
# with no /v1) don't support /models. Rewrite to OpenAI-compat
# /v1 surface for health checks.
# Anthropic-compat endpoints (/anthropic, api.kimi.com/coding with no /v1) don't support
# /models — rewrite to the OpenAI-compat /v1 surface for health checks.
if base and base.rstrip("/").endswith("/anthropic"):
from agent.auxiliary_client import _to_openai_base_url
base = _to_openai_base_url(base)
@@ -211,9 +204,8 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_heal
headers = {"Authorization": f"Bearer {key}", "User-Agent": _HERMES_USER_AGENT}
if base_url_host_matches(base, "api.kimi.com"):
headers["User-Agent"] = "claude-code/0.1.0"
# Google's Generative Language API rejects ``Authorization: Bearer
# <api-key>`` with 401 ``ACCESS_TOKEN_TYPE_UNSUPPORTED`` — that header is
# reserved for OAuth 2 access tokens; plain keys use ``x-goog-api-key``.
# Google's Generative Language API rejects ``Authorization: Bearer <api-key>`` with 401
# ACCESS_TOKEN_TYPE_UNSUPPORTED (reserved for OAuth 2 tokens); plain keys use ``x-goog-api-key``.
if url and base_url_host_matches(url, "generativelanguage.googleapis.com"):
headers.pop("Authorization", None)
headers["x-goog-api-key"] = key
@@ -254,8 +246,7 @@ def _probe_bedrock() -> ProbeResult:
return _row(name, "warn", f"(boto3 not installed — {pip})", [f"Install boto3 for Bedrock: {pip}"], label=label)
except Exception as e:
err_name = type(e).__name__
return _row(name, "warn", f"({err_name}: {e})",
[f"AWS Bedrock: {err_name} — check IAM permissions for bedrock:ListFoundationModels"], label=label)
return _row(name, "warn", f"({err_name}: {e})", [f"AWS Bedrock: {err_name} — check IAM permissions for bedrock:ListFoundationModels"], label=label)
def _probe_azure_entra() -> ProbeResult:
@@ -281,10 +272,7 @@ def _probe_azure_entra() -> ProbeResult:
try:
from agent.azure_identity_adapter import (
EntraIdentityConfig,
SCOPE_AI_AZURE_DEFAULT,
describe_active_credential,
has_azure_identity_installed,
EntraIdentityConfig, SCOPE_AI_AZURE_DEFAULT, describe_active_credential, has_azure_identity_installed,
)
except Exception as exc:
return _row(name, "warn", f"(adapter import failed: {exc})", [f"Azure Foundry adapter import failed: {exc}"], label=label)
@@ -301,10 +289,7 @@ def _probe_azure_entra() -> ProbeResult:
tag = ", ".join(env_sources) if env_sources else "default credential chain"
return _row(name, "ok", f"({tag}, scope={scope})", label=label)
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."
)
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 _row(name, "warn", f"({err})", [f"Azure Foundry Entra: {err}. {hint}"], label=label)
@@ -313,14 +298,12 @@ def build_probes() -> list:
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:
# Bind loop vars via default args so every closure keeps its own provider.
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))
return probes
return [
("OpenRouter API", _probe_openrouter), ("Anthropic API", _probe_anthropic),
# functools.partial binds each row's args so every callable keeps its own provider.
*((row[0], functools.partial(_probe_apikey_provider, *row)) for row in _APIKEY_PROVIDERS_CACHE),
("AWS Bedrock", _probe_bedrock), ("Azure Foundry (Entra ID)", _probe_azure_entra),
]
def run_probes(probes: list) -> list:

View File

@@ -14,19 +14,17 @@ from hermes_cli.doctor import _section, check_fail, check_info, check_ok, check_
DEFAULT_PROBE_TIMEOUT = 10.0
# Metadata-only endpoints. None of these spend generation credits.
FIRECRAWL_HEALTH_URL = "https://api.firecrawl.dev/v2/team/credit-usage"
FAL_MODELS_URL = "https://fal.ai/api/models?page=1"
OPENAI_MODELS_URL = "https://api.openai.com/v1/models"
GROQ_MODELS_URL = "https://api.groq.com/openai/v1/models"
ELEVENLABS_VOICES_URL = "https://api.elevenlabs.io/v1/voices"
# Metadata-only endpoints (none spend generation credits): name -> (url, env var, auth scheme).
_KEYED_PROBES = {
"Firecrawl": ("https://api.firecrawl.dev/v2/team/credit-usage", "FIRECRAWL_API_KEY", "Bearer"),
"FAL": ("https://fal.ai/api/models?page=1", "FAL_KEY", "Key"),
}
# TTS/STT providers that never touch the network (nothing to probe).
_LOCAL_AUDIO_PROVIDERS = {"", "local", "edge", "neutts", "kittentts", "piper"}
_AUDIO_PROBES = {
"openai": (OPENAI_MODELS_URL, "OPENAI_API_KEY", "Bearer"),
"groq": (GROQ_MODELS_URL, "GROQ_API_KEY", "Bearer"),
"elevenlabs": (ELEVENLABS_VOICES_URL, "ELEVENLABS_API_KEY", "xi"),
"openai": ("https://api.openai.com/v1/models", "OPENAI_API_KEY", "Bearer"),
"groq": ("https://api.groq.com/openai/v1/models", "GROQ_API_KEY", "Bearer"),
"elevenlabs": ("https://api.elevenlabs.io/v1/voices", "ELEVENLABS_API_KEY", "xi"),
}
@@ -89,11 +87,8 @@ def _browser_available() -> bool:
def _launch_browser_probe(timeout: float) -> tuple:
"""Launch a browser, open about:blank, close. Returns (ok, detail).
Uses Playwright directly (what agent-browser drives underneath) so the probe owns the full
lifecycle and always cleans up.
"""
"""Launch a browser, open about:blank, close. Returns (ok, detail). Uses Playwright directly (what
agent-browser drives underneath) so the probe owns the full lifecycle and always cleans up."""
try:
from playwright.sync_api import sync_playwright
except ImportError:
@@ -191,11 +186,8 @@ def _run_one(name: str, fn: Callable[[], ProbeResult], issues: List[str]) -> Pro
def run_live_checks(issues: List[str]) -> List[ProbeResult]:
"""Run one bounded, read-only probe per configured tool backend.
Sequential by design (bounded, predictable output ordering). Appends a remediation line to
``issues`` for each failed probe. Skipped backends never fail and never append issues.
"""
"""Run one bounded, read-only probe per configured tool backend — sequential by design (predictable output
ordering). Appends a remediation line to ``issues`` per failed probe; skipped backends never append."""
config = _load_config()
try:
timeout = float((config.get("doctor") or {}).get("live_probe_timeout", DEFAULT_PROBE_TIMEOUT))
@@ -205,10 +197,10 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]:
_section("Live Backend Probes (opt-in, real calls)")
results: List[ProbeResult] = [
_run_one("Firecrawl", lambda: _keyed_probe("Firecrawl", FIRECRAWL_HEALTH_URL, "FIRECRAWL_API_KEY", "Bearer", timeout), issues),
_run_one("FAL", lambda: _keyed_probe("FAL", FAL_MODELS_URL, "FAL_KEY", "Key", timeout), issues),
_run_one("Browser", lambda: _probe_browser(timeout), issues),
_run_one(name, lambda n=name, spec=spec: _keyed_probe(n, *spec, timeout), issues)
for name, spec in _KEYED_PROBES.items()
]
results.append(_run_one("Browser", lambda: _probe_browser(timeout), issues))
servers = config.get("mcp_servers") or {}
if isinstance(servers, dict) and servers:
@@ -232,11 +224,8 @@ def run_live_checks(issues: List[str]) -> List[ProbeResult]:
def maybe_run_live_checks(args, issues: List[str]):
"""Entry point called from ``run_doctor`` after the static checks.
No-ops (returns None) unless the user explicitly passed ``--live``. A crash anywhere in the live
subsystem must never break doctor.
"""
"""Called from ``run_doctor`` after the static checks; no-op (None) unless ``--live`` was passed.
A crash anywhere in the live subsystem must never break doctor."""
if not getattr(args, "live", False):
return None
try:

View File

@@ -60,8 +60,7 @@ def _unreadable_reason(db_path: Path) -> str:
"""Explain why a database file could not be read, without opening it.
``read_header_bytes_preopen`` collapses every ``OSError`` into ``None``, but doctor must say *which*
problem it hit. ``stat()`` and ``access()`` answer that from directory metadata alone — neither takes
a file descriptor, so neither can cancel the file's POSIX advisory locks.
problem it hit. ``stat()``/``access()`` answer from directory metadata alone — no descriptor, no lock loss.
"""
try:
db_path.stat()
@@ -74,10 +73,9 @@ def _read_journal_mode(db_path: Path) -> tuple[str | None, str | None]:
"""Return (journal mode, error) from header byte 18 (2=WAL, 1=rollback) without opening the database.
Opening through SQLite — even read-only — creates -wal/-shm sidecars, which a diagnostic must not do.
The read goes through ``read_header_bytes_preopen`` rather than a bare ``open()``: closing *any*
descriptor cancels this process's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``), and the
dashboard console runs ``run_doctor`` in-process with live ``SessionDB`` connections — the helper
refuses then and the mode is reported as unreadable.
``read_header_bytes_preopen`` rather than a bare ``open()``: closing *any* descriptor cancels this
process's POSIX advisory locks (see ``hermes_cli.sqlite_safe_read``), and the dashboard console runs
``run_doctor`` in-process with live ``SessionDB`` connections — the helper refuses then (unreadable).
"""
from hermes_cli.sqlite_safe_read import has_live_connection, read_header_bytes_preopen
@@ -154,10 +152,8 @@ def _read_pyproject_version() -> str | None:
def _check_version_consistency(issues: list[str]) -> None:
"""Detect pyproject.toml vs hermes_cli.__version__ drift (a git conflict resolution can revert one but not the other).
Silent no-op for installed wheels (no pyproject).
"""
"""Detect pyproject.toml vs hermes_cli.__version__ drift (a conflict resolution can revert one but not the
other). Silent no-op for installed wheels (no pyproject)."""
try:
from hermes_cli import __version__ as init_version
except Exception:
@@ -168,20 +164,15 @@ def _check_version_consistency(issues: list[str]) -> None:
if pyproject_version == init_version:
check_ok("Version files consistent", f"({init_version})")
else:
_fail_and_issue(
"Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)",
issues,
)
_fail_and_issue("Version mismatch between source files",
f"(pyproject.toml {pyproject_version} != hermes_cli/__init__.py {init_version})",
"Re-sync version files (e.g. run 'hermes update', or set "
"hermes_cli/__init__.py __version__ to match pyproject.toml)", issues)
def _check_s6_supervision(issues: list[str]) -> None:
"""Inside a container under our s6 /init, report static services and per-profile gateway slots that are ``up``.
Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host); no-op outside the s6 container.
"""
"""Under our s6 /init, report static services and per-profile gateway slots that are ``up``; no-op elsewhere.
Counterpart to :func:`_check_gateway_service_linger` (systemd-on-host)."""
try:
from hermes_cli.service_manager import S6ServiceManager, detect_service_manager
except Exception:
@@ -192,10 +183,8 @@ def _check_s6_supervision(issues: list[str]) -> None:
_section("s6 Supervision")
mgr = S6ServiceManager()
for static in ("main-hermes", "dashboard"): # s6-rc symlinks under /run/service/, same s6-svstat probe
if mgr.is_running(static):
check_ok(f"{static}: up")
else:
check_info(f"{static}: down (expected if not enabled via env)")
(check_ok if mgr.is_running(static) else check_info)(
f"{static}: up" if mgr.is_running(static) else f"{static}: down (expected if not enabled via env)")
profiles = mgr.list_profile_gateways()
if not profiles:
@@ -209,8 +198,8 @@ def _check_s6_supervision(issues: list[str]) -> None:
def check_certificates(should_fix: bool = False, issues: "list | None" = None) -> None:
"""Verify the certifi CA bundle is loadable before the first HTTPS call tracebacks.
``--fix`` repairs a broken bundle (e.g. a brew Python upgrade rebuilt the venv) by
force-reinstalling certifi into THIS interpreter's environment and re-verifying.
``--fix`` repairs a broken bundle (e.g. a brew Python upgrade rebuilt the venv) by force-reinstalling
certifi into THIS interpreter's environment and re-verifying.
"""
try:
from agent.ssl_guard import verify_ca_bundle_with_fallback
@@ -271,10 +260,7 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
def _check_gateway_service_linger(issues: list[str]) -> None:
"""Warn when a systemd user gateway service will stop after logout.
Skipped under s6 (no systemd, no logout, no linger concept; ``_check_s6_supervision`` reports that state).
"""
"""Warn when a systemd user gateway service will stop after logout (skipped under s6: no linger concept)."""
try:
from hermes_cli.gateway import get_systemd_linger_status, get_systemd_unit_path, is_linux
from hermes_cli.service_manager import detect_service_manager
@@ -297,13 +283,12 @@ def _check_gateway_service_linger(issues: list[str]) -> None:
def check_macos_tcc_grants() -> None:
"""Check macOS TCC grant persistence for a locally-built desktop bundle.
"""Check macOS TCC grant persistence for a locally-built desktop bundle; silent on non-macOS / no bundle.
TCC keys grants to the app's designated requirement (DR). A cdhash-pinned ad-hoc DR changes on every
rebuild, so grants silently stop matching while the Settings toggle stays ON and macOS re-prompts;
identifier-pinned builds survive rebuilds, but grants made to older binaries stay stale until re-granted
once. TCC.db needs Full Disk Access, so the DR string is the only readable signal — a cdhash anchor is a
proxy for the signing class, not a contract on DR wording. Silent on non-macOS / no bundle.
rebuild, so grants silently stop matching while the Settings toggle stays ON; identifier-pinned builds
survive rebuilds, but grants made to older binaries stay stale until re-granted once. TCC.db needs Full
Disk Access, so the DR string is the only readable signal (a proxy for the signing class, not DR wording).
"""
from hermes_cli.doctor import _desktop_app_bundle, _macos_desktop_dr
if sys.platform != "darwin":
@@ -324,14 +309,10 @@ def check_macos_tcc_grants() -> None:
"identity, then re-grant permissions once.",
)
return
if "certificate" in dr.lower(): # --setup-tcc-identity or notarized build: strongest anchor
check_ok("macOS TCC signing identity is stable", "(certificate-anchored DR; grants survive rebuilds)")
else:
check_ok(
"macOS TCC signing identity is stable",
"(identifier-pinned DR; grants survive rebuilds — for the strongest "
"anchor, see `hermes desktop --setup-tcc-identity`)",
)
check_ok("macOS TCC signing identity is stable",
# --setup-tcc-identity or notarized build: strongest anchor
"(certificate-anchored DR; grants survive rebuilds)" if "certificate" in dr.lower() else
"(identifier-pinned DR; grants survive rebuilds — for the strongest anchor, see `hermes desktop --setup-tcc-identity`)")
check_info(
"If macOS still re-prompts for permissions (toggle shows ON): the stored "
"grant is stale — run `tccutil reset ScreenCapture com.nousresearch.hermes` "
@@ -341,10 +322,10 @@ def check_macos_tcc_grants() -> None:
def _desktop_app_bundle() -> Path | None:
"""Locate the locally-built desktop bundle (``apps/desktop/release/mac-<arch>/Hermes.app``), newest arch tree first.
"""Locate the locally-built desktop bundle (``apps/desktop/release/mac-<arch>/Hermes.app``), newest first.
That is the only layout whose ad-hoc re-signed bundle can invalidate TCC grants. ``/Applications/Hermes.app``
is deliberately not probed: it is the separately-signed, certificate-anchored Hermes-Setup launcher.
The only layout whose ad-hoc re-signed bundle can invalidate TCC grants. ``/Applications/Hermes.app`` is
deliberately not probed: it is the separately-signed, certificate-anchored Hermes-Setup launcher.
"""
release_dir = Path(__file__).resolve().parents[1] / "apps" / "desktop" / "release"
candidates = [p for p in release_dir.glob("mac*/Hermes.app") if p.is_dir()]
@@ -365,9 +346,7 @@ def _macos_desktop_dr(app: Path) -> str | None:
def check_macos_tcc_anchor(should_fix: bool = False) -> None:
"""Report (and with --fix install) the dylib-complete TCC anchor; silent on non-macOS / non-uv interpreters.
Never raises. Install is gated by the module's pre-install boot probe, so ``--fix`` cannot brick the CLI.
"""
Never raises. Install is gated by the module's pre-install boot probe, so ``--fix`` cannot brick the CLI."""
try:
from hermes_cli import macos_tcc_anchor as tcc
@@ -391,8 +370,7 @@ def check_macos_full_disk_access() -> None:
"""One-grant guidance: Full Disk Access silences every per-folder TCC prompt. Silent on non-macOS.
Probe: listdir of ``~/Library/Application Support/com.apple.TCC`` — FDA-gated, and probing it does NOT
trigger a prompt (prompts fire for protected-CATEGORY paths like Desktop; the TCC dir just returns EPERM).
A missing dir / other error is indeterminate, so stay silent rather than nag.
trigger a prompt (the TCC dir just returns EPERM). A missing dir / other error is indeterminate: stay silent.
"""
if sys.platform != "darwin":
return
@@ -448,26 +426,24 @@ def _check_python_environment(should_fix: bool) -> Finding:
f = Finding()
v = sys.version_info
label = f"Python {v.major}.{v.minor}.{v.micro}"
if v >= (3, 11):
if v >= (3, 10):
check_ok(label)
elif v >= (3, 10):
check_ok(label)
check_warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
if v < (3, 11):
check_warn("Python 3.11+ recommended for RL Training tools (tinker requires >= 3.11)")
elif v >= (3, 8):
check_warn(label, "(3.10+ recommended)")
else:
_fail_and_issue(label, "(3.10+ required)", "Upgrade Python to 3.10+", f.issues)
# Linked SQLite: version + source id matter independently of the Python minor
# (uv's python-build-standalone can keep a vulnerable SQLite across upgrades).
# Linked SQLite: version + source id matter independently of the Python minor (uv's
# python-build-standalone can keep a vulnerable SQLite across upgrades).
try:
import sqlite3
from hermes_state import is_sqlite_wal_reset_vulnerable, sqlite_source_id
src = sqlite_source_id()
if is_sqlite_wal_reset_vulnerable():
# Warn-only: Hermes already refuses to enable WAL on fresh DBs, and
# runtime repair is best-effort, so this never goes into ``issues``.
# Warn-only: Hermes already refuses WAL on fresh DBs and runtime repair is best-effort.
check_warn(f"SQLite {sqlite3.sqlite_version} (WAL-reset bug)", _sqlite_upgrade_hint())
else:
check_ok(f"SQLite {sqlite3.sqlite_version}")
@@ -491,21 +467,25 @@ def _check_certificates(should_fix: bool) -> Finding:
return f
# (import name, display name, optional)
_PACKAGES = (
("openai", "OpenAI SDK", False), ("rich", "Rich (terminal UI)", False), ("dotenv", "python-dotenv", False),
("yaml", "PyYAML", False), ("httpx", "HTTPX", False),
("croniter", "Croniter (cron expressions)", True), ("telegram", "python-telegram-bot", True), ("discord", "discord.py", True),
)
def _check_required_packages(should_fix: bool) -> Finding:
f = Finding()
for module, name in (("openai", "OpenAI SDK"), ("rich", "Rich (terminal UI)"), ("dotenv", "python-dotenv"),
("yaml", "PyYAML"), ("httpx", "HTTPX")):
for module, name, optional in _PACKAGES:
try:
__import__(module)
check_ok(name)
check_ok(name, "(optional)" if optional else "")
except ImportError:
_fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", f.issues)
for module, name in (("croniter", "Croniter (cron expressions)"), ("telegram", "python-telegram-bot"), ("discord", "discord.py")):
try:
__import__(module)
check_ok(name, "(optional)")
except ImportError:
check_warn(name, "(optional, not installed)")
if optional:
check_warn(name, "(optional, not installed)")
else:
_fail_and_issue(name, "(missing)", f"Install {name}: {_python_install_cmd()} {module}", f.issues)
return f
@@ -548,9 +528,7 @@ def _check_command_installation(should_fix: bool) -> Finding:
f.issues.append(f"Broken symlink at {display}/hermes — run 'hermes doctor --fix'")
return f
link.unlink()
link.symlink_to(venv_bin)
check_ok(f"Fixed symlink: {display}/hermes → {venv_bin}")
f.fixed += 1
_link_venv(f, link, venv_bin, f"Fixed symlink: {display}/hermes → {venv_bin}")
elif link.exists(): # regular file (wrapper script), not a symlink
check_ok(f"{display}/hermes exists (non-symlink)")
else:
@@ -559,10 +537,14 @@ def _check_command_installation(should_fix: bool) -> Finding:
f.issues.append(f"Missing {display}/hermes symlink — run 'hermes doctor --fix'")
return f
link_dir.mkdir(parents=True, exist_ok=True)
link.symlink_to(venv_bin)
check_ok(f"Created symlink: {display}/hermes → {venv_bin}")
f.fixed += 1
_link_venv(f, link, venv_bin, f"Created symlink: {display}/hermes → {venv_bin}")
if str(link_dir) not in os.environ.get("PATH", "").split(os.pathsep):
check_warn(f"{display} is not on your PATH", "(add it to your shell config: export PATH=\"$HOME/.local/bin:$PATH\")")
f.manual_issues.append(f"Add {display} to your PATH")
return f
def _link_venv(f: Finding, link: Path, venv_bin: Path, msg: str) -> None:
link.symlink_to(venv_bin)
check_ok(msg)
f.fixed += 1

View File

@@ -9,7 +9,7 @@ from __future__ import annotations
import subprocess
from pathlib import Path
from hermes_cli.doctor_report import (
Finding, _fail_and_issue, _section, check_info, check_ok, check_warn, doctor_check, ensure_dir,
Finding, _fail_and_issue, _section, check_bool, check_info, check_ok, check_warn, doctor_check, ensure_dir,
)
from hermes_cli.sizefmt import format_bytes as _human_bytes
@@ -28,11 +28,10 @@ def _honcho_is_configured_for_doctor() -> bool:
def _doctor_memory_config(hermes_home: Path | None = None) -> dict:
"""Return the effective memory section used by doctor diagnostics."""
from hermes_cli.doctor import HERMES_HOME
home = hermes_home if hermes_home is not None else HERMES_HOME
try:
from hermes_cli.config import _expand_env_vars, read_user_config_raw
config_path = home / "config.yaml"
config_path = (hermes_home if hermes_home is not None else HERMES_HOME) / "config.yaml"
if not config_path.exists():
return {}
config = _expand_env_vars(read_user_config_raw(config_path))
@@ -173,11 +172,9 @@ def _check_directory_structure(should_fix: bool) -> Finding:
check_ok(f"{_DHH}/memories/ directory exists")
for enabled, fname in ((_memory_enabled, "MEMORY.md"), (_user_profile_enabled, "USER.md")):
mem_file = memories_dir / fname
if not enabled:
continue
if mem_file.exists():
if enabled and mem_file.exists():
check_ok(f"{fname} exists ({len(mem_file.read_text(encoding='utf-8').strip())} chars)")
else:
elif enabled:
check_info(f"{fname} not created yet (will be created when the agent first writes a memory)")
else:
check_warn(f"{_DHH}/memories/ not found", "(will be created on first use)")
@@ -214,10 +211,9 @@ def _repair_state_db(f: Finding, should_fix: bool, state_db_path: Path, *,
return
if callable(ok_label):
try:
count = _session_count(state_db_path)
ok_label = ok_label(_session_count(state_db_path))
except Exception:
count = "?"
ok_label = ok_label(count)
ok_label = ok_label("?")
backup_name = Path(report["backup_path"]).name if report.get("backup_path") else "n/a"
check_ok(ok_label, f"(strategy: {report.get('strategy')}; backup: {backup_name})")
f.fixed += 1
@@ -337,7 +333,9 @@ def _check_skills_hub(should_fix: bool) -> Finding:
from hermes_cli.doctor import HERMES_HOME, _DHH
f = Finding()
hub_dir = HERMES_HOME / "skills" / ".hub"
if hub_dir.exists():
if not hub_dir.exists():
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
else:
check_ok("Skills Hub directory exists")
lock_file = hub_dir / "lock.json"
if lock_file.exists():
@@ -351,17 +349,14 @@ def _check_skills_hub(should_fix: bool) -> Finding:
q_count = sum(1 for d in quarantine.iterdir() if d.is_dir()) if quarantine.exists() else 0
if q_count > 0:
check_warn(f"{q_count} skill(s) in quarantine", "(pending review)")
else:
check_warn("Skills Hub directory not initialized", "(run: hermes skills list)")
from hermes_cli.config import get_env_value
if get_env_value("GITHUB_TOKEN") or get_env_value("GH_TOKEN"):
check_ok("GitHub token configured (authenticated API access)")
elif _gh_authenticated():
check_ok("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)")
else:
check_warn("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)")
check_bool(_gh_authenticated(), ("GitHub authenticated via gh CLI", "(full API access — no GITHUB_TOKEN needed)"),
("No GITHUB_TOKEN", f"(60 req/hr rate limit — set in {_DHH}/.env for better rates)"))
return f
@@ -371,11 +366,9 @@ def _memory_provider_honcho(issues: list) -> None:
cfg_path = resolve_config_path()
if not cfg_path.exists():
# Config file missing — env-var fallback may still have resolved it.
if hcfg.api_key or hcfg.base_url:
check_ok("Honcho configured via environment variables",
f"config file {cfg_path} not found, using HONCHO_API_KEY env var")
else:
check_warn("Honcho config not found", "run: hermes memory setup")
check_bool(hcfg.api_key or hcfg.base_url,
("Honcho configured via environment variables", f"config file {cfg_path} not found, using HONCHO_API_KEY env var"),
("Honcho config not found", "run: hermes memory setup"))
elif not hcfg.enabled:
check_info(f"Honcho disabled (set enabled: true in {cfg_path} to activate)")
elif not (hcfg.api_key or hcfg.base_url):
@@ -460,10 +453,9 @@ def _check_profiles(should_fix: bool, f: Finding) -> None:
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")
for missing, text in (("config.yaml", "⚠ missing config"), (".env", "no .env")):
if not (p.path / missing).exists():
parts.append(text)
if not (wrapper_dir / p.name).exists():
parts.append("no alias")
check_ok(f" {p.name}: {', '.join(parts) if parts else 'configured'}")

View File

@@ -72,8 +72,7 @@ def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
from agent.web_search_registry import get_active_extract_provider, get_active_search_provider
from tools.web_tools import _ensure_web_plugins_loaded, _provider_is_ready
# Doctor is a fresh process: bundled web providers only register during plugin
# discovery, which nothing has triggered yet (idempotent, cheap on rerun).
# Fresh process: bundled web providers only register during plugin discovery (idempotent, cheap).
_ensure_web_plugins_loaded()
except Exception:
return rows
@@ -87,10 +86,8 @@ def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
rows.append(("warn", capability, "(no provider selected or registered)"))
continue
name = getattr(provider, "name", None) or type(provider).__name__
if _provider_is_ready(provider):
rows.append(("ok", capability, f"({name})"))
else:
rows.append(("warn", capability, f"({name} selected; provider not configured)"))
rows.append(("ok", capability, f"({name})") if _provider_is_ready(provider)
else ("warn", capability, f"({name} selected; provider not configured)"))
return rows
@@ -149,10 +146,9 @@ def _check_docker_backend(terminal_env: str, running_in_container: bool, issues:
if terminal_env == "docker":
if not _safe_which("docker"):
_fail_and_issue("docker not found", "(required for TERMINAL_ENV=docker)", "Install Docker or change TERMINAL_ENV", issues)
elif _run_ok(["docker", "info"], timeout=10):
check_ok("docker", "(daemon running)")
else:
_fail_and_issue("docker daemon not running", "", "Start Docker daemon", issues)
_require(_run_ok(["docker", "info"], timeout=10), ("docker", "(daemon running)"), ("docker daemon not running", ""),
"Start Docker daemon", issues)
elif _safe_which("docker"):
check_ok("docker", "(optional)")
elif _is_termux():
@@ -173,17 +169,19 @@ def _check_ssh_backend(issues: list[str]) -> None:
if ssh_key:
cmd += ["-i", os.path.expanduser(ssh_key)]
cmd += [f"{ssh_user}@{ssh_host}" if ssh_user else ssh_host, "echo ok"]
if _run_ok(cmd, timeout=15, text=True, encoding='utf-8', errors='replace'):
check_ok(f"SSH connection to {ssh_host}")
else:
_fail_and_issue(f"SSH connection to {ssh_host}", "", f"Check SSH configuration for {ssh_host}", issues)
_require(_run_ok(cmd, timeout=15, text=True, encoding='utf-8', errors='replace'),
f"SSH connection to {ssh_host}", (f"SSH connection to {ssh_host}", ""), f"Check SSH configuration for {ssh_host}", issues)
def _require(cond, ok, bad, issue: str, issues: list[str]) -> None:
"""``check_ok(*ok)`` when *cond*, else ``check_fail(*bad)`` and record *issue*."""
if not check_bool(cond, ok, bad, fail=True):
issues.append(issue)
def _check_daytona_backend(issues: list[str]) -> None:
if os.getenv("DAYTONA_API_KEY"):
check_ok("Daytona API key", "(configured)")
else:
_fail_and_issue("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)", "Set DAYTONA_API_KEY environment variable", issues)
_require(os.getenv("DAYTONA_API_KEY"), ("Daytona API key", "(configured)"),
("DAYTONA_API_KEY not set", "(required for TERMINAL_ENV=daytona)"), "Set DAYTONA_API_KEY environment variable", issues)
try:
from daytona import Daytona # noqa: F401 — SDK presence check
check_ok("daytona SDK", "(installed)")
@@ -194,23 +192,15 @@ def _check_daytona_backend(issues: list[str]) -> None:
def _check_vercel_backend(issues: list[str]) -> None:
from tools.terminal_tool import _SUPPORTED_VERCEL_RUNTIMES
runtime = os.getenv("TERMINAL_VERCEL_RUNTIME", "node24").strip() or "node24"
if runtime in _SUPPORTED_VERCEL_RUNTIMES:
check_ok("Vercel runtime", f"({runtime})")
else:
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
_fail_and_issue("Vercel runtime unsupported", f"({runtime}; use {supported})", f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", issues)
if os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() in {"", "0", "51200"}:
check_ok("Vercel disk setting", "(uses platform default)")
else:
_fail_and_issue("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)",
"Vercel Sandbox does not support custom container_disk; use the shared default 51200", issues)
if importlib.util.find_spec("vercel") is not None:
check_ok("vercel SDK", "(installed)")
else:
_fail_and_issue("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')",
"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", issues)
supported = ", ".join(_SUPPORTED_VERCEL_RUNTIMES)
_require(runtime in _SUPPORTED_VERCEL_RUNTIMES, ("Vercel runtime", f"({runtime})"),
("Vercel runtime unsupported", f"({runtime}; use {supported})"), f"Set TERMINAL_VERCEL_RUNTIME to one of: {supported}", issues)
_require(os.getenv("TERMINAL_CONTAINER_DISK", "51200").strip() in {"", "0", "51200"},
("Vercel disk setting", "(uses platform default)"), ("Vercel custom disk unsupported", "(reset terminal.container_disk to 51200)"),
"Vercel Sandbox does not support custom container_disk; use the shared default 51200", issues)
_require(importlib.util.find_spec("vercel") is not None, ("vercel SDK", "(installed)"),
("vercel SDK not installed", "(pip install 'hermes-agent[vercel]')"),
"Install the Vercel optional dependency: pip install 'hermes-agent[vercel]'", issues)
auth_status = describe_vercel_auth()
if auth_status.ok:
@@ -222,11 +212,9 @@ def _check_vercel_backend(issues: list[str]) -> None:
"Configure Vercel Sandbox auth with VERCEL_TOKEN, VERCEL_PROJECT_ID, and VERCEL_TEAM_ID", issues)
for line in auth_status.detail_lines:
check_info(f"Vercel auth {line}")
if os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}:
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation")
else:
check_info("Vercel persistence: ephemeral filesystem")
persistent = os.getenv("TERMINAL_CONTAINER_PERSISTENT", "true").lower() in {"1", "true", "yes", "on"}
check_info("Vercel persistence: snapshot filesystem only; live processes do not survive sandbox recreation"
if persistent else "Vercel persistence: ephemeral filesystem")
def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None:
@@ -244,10 +232,7 @@ def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None:
"Fix terminal.backend in config.yaml, or install/enable the plugin that provides it", issues)
return
for ok, label, detail in provider.doctor_checks():
if ok:
check_ok(label, detail)
else:
_fail_and_issue(label, detail, detail.strip("()"), issues)
_require(ok, (label, detail), (label, detail), detail.strip("()"), issues)
_BACKEND_CHECKS = {"ssh": _check_ssh_backend, "daytona": _check_daytona_backend, "vercel_sandbox": _check_vercel_backend}
@@ -263,9 +248,8 @@ def _check_terminal_backend(should_fix: bool) -> Finding:
except Exception:
running_in_container = False
# Inside our container docker-in-docker isn't set up, so the local backend is the
# intended one: skip the noisy "docker not found" warning. An explicit
# TERMINAL_ENV=docker (user likely mounted docker.sock) still gets the normal check.
# Inside our container docker-in-docker isn't set up, so the local backend is the intended one: skip the
# noisy "docker not found" warning. An explicit TERMINAL_ENV=docker (mounted docker.sock) still gets checked.
if running_in_container and terminal_env != "docker":
check_info("Running inside a container — using local terminal backend (docker-in-docker is not configured by default)")
terminal_env = "local"
@@ -280,9 +264,8 @@ def _check_terminal_backend(should_fix: bool) -> Finding:
def _check_agent_browser(should_fix: bool) -> bool:
"""agent-browser resolution; returns True when browser tools will find a usable install.
Mirrors ``tools.browser_tool._find_agent_browser``'s own cascade (it resolves lazily via
npx or a global/Hermes-managed install) so doctor can't diverge from what the tools find;
validate=False keeps this a cheap existence check with no subprocess or install side effects.
Mirrors ``tools.browser_tool._find_agent_browser``'s own cascade (lazy npx or a global/Hermes-managed
install) so doctor can't diverge from the tools; validate=False keeps it a cheap, side-effect-free check.
"""
try:
from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel
@@ -293,20 +276,16 @@ def _check_agent_browser(should_fix: bool) -> bool:
if resolved and _is_npx_agent_browser_sentinel(resolved):
check_ok("agent-browser", "(resolves via npx on first use)")
if should_fix:
# Can't tell from here whether npx's cache is warm — fire the same warm-up
# `hermes update` does so the first browser call doesn't pay the registry fetch.
# Can't tell whether npx's cache is warm — fire the same warm-up `hermes update` does.
from tools.browser_tool import warm_agent_browser_npx_cache
if warm_agent_browser_npx_cache():
check_info(" Warmed npx cache for agent-browser")
else:
check_info(" Could not warm npx cache (offline or npx unavailable)")
check_info(" Warmed npx cache for agent-browser" if warm_agent_browser_npx_cache()
else " Could not warm npx cache (offline or npx unavailable)")
return True
if resolved and agent_browser_runnable(resolved):
check_ok("agent-browser", "(browser automation)")
return True
if resolved:
# Almost always a dangling global symlink left by agent-browser's npm
# postinstall after `hermes update` wiped node_modules.
# Almost always a dangling global symlink left by npm postinstall after `hermes update` wiped node_modules.
check_warn("agent-browser found but not runnable", f"(broken symlink at {resolved}? run: npx agent-browser --version)")
elif _is_termux():
_termux_browser_hints(
@@ -330,9 +309,8 @@ def _termux_browser_hints(*lines: str, node_installed: bool) -> None:
def _check_chromium() -> None:
"""Playwright Chromium presence, using the exact predicate browser_tool uses to hide browser_* tools.
Lazy import: browser_tool is a ~150KB module; if it can't import that is a separate
bug surfaced elsewhere. Camofox, a CDP override, a cloud provider, or Lightpanda all
bypass the local Chromium requirement, so no warning is emitted for them.
Lazy import: browser_tool is ~150KB; an import failure is a separate bug surfaced elsewhere. Camofox, a
CDP override, a cloud provider, or Lightpanda all bypass the local Chromium requirement (no warning).
"""
from hermes_cli.doctor import PROJECT_ROOT
try:
@@ -342,12 +320,10 @@ def _check_chromium() -> None:
return
if _is_camofox_mode() or bool(_get_cdp_override_raw()) or _get_cloud_provider() is not None or _using_lightpanda_engine():
return
if _chromium_installed():
check_ok("Playwright Chromium", "(browser engine)")
return
check_warn("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)")
with_deps = "" if sys.platform == "win32" else "--with-deps "
check_info(f"Install with: cd {PROJECT_ROOT} && npx playwright install {with_deps}chromium")
if not check_bool(_chromium_installed(), ("Playwright Chromium", "(browser engine)"),
("Playwright Chromium not installed", "(browser_* tools will be hidden from the agent)")):
with_deps = "" if sys.platform == "win32" else "--with-deps "
check_info(f"Install with: cd {PROJECT_ROOT} && npx playwright install {with_deps}chromium")
def _check_lightpanda() -> None:
@@ -367,10 +343,8 @@ def _check_lightpanda() -> None:
if not used:
check_warn("browser.engine=lightpanda is shadowed", f"({reason})")
check_info("Fix: pick Lightpanda in `hermes tools` → Browser Automation, or set browser.engine: auto")
elif find_lightpanda_binary():
check_ok("Lightpanda", f"({reason})")
else:
check_warn("Lightpanda selected but binary not found", "(browser tools will fail until it is installed)")
elif not check_bool(find_lightpanda_binary(), ("Lightpanda", f"({reason})"),
("Lightpanda selected but binary not found", "(browser tools will fail until it is installed)")):
check_info(LIGHTPANDA_INSTALL_HINT)
@@ -400,10 +374,9 @@ def _plural(n: int) -> str:
def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues: list[str]) -> None:
"""Run one `npm audit --json` and report; any failure is silently skipped.
Workspace-scoped (`--workspace <name>`) advisories are build-time tooling (esbuild/vite),
not runtime code. `npm audit fix --workspace` crashes on current npm (arborist "edgesOut"),
and the root-level fix can crash on the same tree ("isDescendantOf"), so no manual fix
command is offered for those — they clear via a lockfile bump.
Workspace-scoped (`--workspace <name>`) advisories are build-time tooling (esbuild/vite), not runtime
code. `npm audit fix --workspace` crashes on current npm (arborist "edgesOut") and the root-level fix can
crash on the same tree ("isDescendantOf"), so no manual fix command is offered — they clear via a lockfile bump.
"""
import json
try:
@@ -437,10 +410,9 @@ def _audit_one(npm_bin: str, npm_dir, label: str, audit_extra: list[str], issues
def _check_npm_audit(should_fix: bool) -> Finding:
"""npm audit per Node package tree (root, web/ui-tui workspaces, WhatsApp bridge).
PROJECT_ROOT is audited with --workspaces=false so the apps/* glob (Electron, node-pty, ...)
is never resolved for a routine security check; web and ui-tui are audited via --workspace.
The WhatsApp bridge may live under a writable HERMES_HOME mirror rather than the (possibly
read-only) install tree in Docker, so it is resolved through the shared helper.
PROJECT_ROOT is audited with --workspaces=false so the apps/* glob (Electron, node-pty, ...) is never
resolved for a routine check; web and ui-tui via --workspace. The WhatsApp bridge may live under a writable
HERMES_HOME mirror rather than the (possibly read-only) Docker install tree, hence the shared resolver.
"""
from hermes_cli.doctor import PROJECT_ROOT
f = Finding()
@@ -457,10 +429,8 @@ def _check_npm_audit(should_fix: bool) -> Finding:
(PROJECT_ROOT, "ui-tui workspace", ["--workspace", "ui-tui"]),
(whatsapp_bridge_dir, "WhatsApp bridge", []),
):
# Workspace-scoped audits run from PROJECT_ROOT check the root node_modules;
# standalone dirs (whatsapp-bridge) check their own.
check_dir = PROJECT_ROOT if audit_extra else npm_dir
if (check_dir / "node_modules").exists():
# Workspace-scoped audits check the root node_modules; standalone dirs check their own.
if ((PROJECT_ROOT if audit_extra else npm_dir) / "node_modules").exists():
_audit_one(npm_bin, npm_dir, label, audit_extra, f.issues)
if _is_termux():

View File

@@ -14,11 +14,8 @@ from agent.skill_utils import is_excluded_skill_path
def _dotenv_key_names() -> set[str]:
"""Return the set of env-var names assigned a non-empty value in ~/.hermes/.env.
The managed backends (launchd / systemd / the desktop-spawned ``serve`` process) load credentials
from this file — NOT from an interactive shell's exports, which ``os.getenv`` reflects here.
"""
"""Env-var names assigned a non-empty value in ~/.hermes/.env — what the managed backends (launchd /
systemd / desktop ``serve``) load, as opposed to the shell exports ``os.getenv`` reflects here."""
try:
text = get_env_path().read_text(encoding="utf-8", errors="ignore")
except (OSError, UnicodeError):
@@ -52,11 +49,8 @@ def _git_output(project_root: Path, *args: str) -> str:
def _get_git_commit(project_root: Path) -> str:
"""Return short git commit hash, or '(unknown)'.
Source installs resolve live via ``git rev-parse``. The published Docker image excludes ``.git``,
so fall back to the build SHA baked into ``<project_root>/.hermes_build_sha`` by the Dockerfile.
"""
"""Short git commit hash, or '(unknown)'. Docker images exclude ``.git``, so fall back to the build SHA
the Dockerfile bakes into ``<project_root>/.hermes_build_sha``."""
value = _git_output(project_root, "rev-parse", "--short=8", "HEAD")
if value:
return value
@@ -185,8 +179,8 @@ _API_KEYS = [
def _version_line(project_root: Path) -> str:
"""``<version> [<commit>] (<commit date>)`` — the commit date is the real "as-of" date;
__release_date__ is intentionally NOT shown (reads like a wall-clock timestamp, confuses triage)."""
"""``<version> [<commit>] (<commit date>)`` — the commit date is the real "as-of" date; __release_date__
is intentionally NOT shown (reads like a wall-clock timestamp, confuses triage)."""
try:
from hermes_cli import __version__
except ImportError:
@@ -197,9 +191,8 @@ def _version_line(project_root: Path) -> str:
def _effective_terminal_backend(config: dict) -> str:
"""The EFFECTIVE backend, not just config.yaml: a TERMINAL_ENV set directly in .env / the shell
overrides ``terminal.backend`` and is what terminal_tool actually uses. run_dump() has already
loaded .env, so os.environ reflects the real override here."""
"""The EFFECTIVE backend: a TERMINAL_ENV set directly in .env / the shell overrides ``terminal.backend`` and
is what terminal_tool uses. run_dump() has already loaded .env, so os.environ reflects the override."""
config_backend = config.get("terminal", {}).get("backend", "local")
env_backend = (os.environ.get("TERMINAL_ENV") or "").strip().lower()
if env_backend and env_backend != str(config_backend).strip().lower():
@@ -213,13 +206,11 @@ def _api_key_lines(show_keys: bool) -> list[str]:
for env_var, label in _API_KEYS:
val = os.getenv(env_var, "")
display = _redact(val) if show_keys and val else ("set" if val else "not set")
# Set in this (shell) process but absent from ~/.hermes/.env: a managed backend
# (launchd/systemd/desktop `serve`) loads .env, not the login shell, so it likely can't
# see this key — flag it so support doesn't chase a phantom "key is configured".
# Set in this shell but absent from ~/.hermes/.env: a managed backend loads .env, not the login
# shell, so it likely can't see this key — flag it so support doesn't chase a phantom "configured".
if val and env_var not in dotenv_keys:
display += " (shell only — not in .env; managed/desktop backend may not see it)"
# A credential added via `hermes auth add openrouter` lives in the credential pool, not as an
# env var — surface it so the dump doesn't read "not set" while `hermes auth list` shows it.
# `hermes auth add openrouter` credentials live in the pool, not env — don't read "not set".
if not val and label == "openrouter":
try:
from agent.credential_pool import load_pool as _load_pool
@@ -232,6 +223,14 @@ def _api_key_lines(show_keys: bool) -> list[str]:
return lines
def _openai_version() -> str:
try:
import openai
return openai.__version__
except ImportError:
return "not installed"
def run_dump(args):
"""Output a compact, copy-pasteable setup summary."""
show_keys = getattr(args, "show_keys", False)
@@ -250,11 +249,7 @@ def run_dump(args):
profile = get_active_profile_name() or "(default)"
except Exception:
profile = "(default)"
try:
import openai
openai_ver = openai.__version__
except ImportError:
openai_ver = "not installed"
openai_ver = _openai_version()
lines = [
"--- hermes dump ---",