refactor(hermes_cli): doctor — extract _apikey_request/_bits/_link_venv, collapse journal-mode & certifi branches, drop blank-after-import lines
This commit is contained in:
@@ -81,7 +81,6 @@ def _check_auth_providers(should_fix: bool) -> Finding:
|
||||
f = Finding()
|
||||
try:
|
||||
from hermes_cli.auth import get_nous_auth_status_local, get_codex_auth_status, get_minimax_oauth_auth_status
|
||||
|
||||
# Read-only display: refresh-free snapshot — doctor must never trigger an OAuth refresh.
|
||||
_login_row("Nous Portal auth", get_nous_auth_status_local())
|
||||
codex_status = get_codex_auth_status()
|
||||
|
||||
@@ -68,12 +68,8 @@ def collect_deprecated_env_vars(env_map: dict | None) -> list[tuple[str, str]]:
|
||||
def collect_relay_plugin_cutover_findings(raw_config: dict | None, env_map: dict | None) -> list[tuple[str, str]]:
|
||||
"""Return actionable findings for the removed Hermes Relay plugin."""
|
||||
from hermes_cli.relay_plugin_cutover import (
|
||||
LEGACY_RELAY_EXPORT_ENV_VARS,
|
||||
RELAY_PLUGINS_CONFIG_ENV,
|
||||
configured_legacy_relay_env_vars,
|
||||
legacy_relay_plugin_keys,
|
||||
LEGACY_RELAY_EXPORT_ENV_VARS, RELAY_PLUGINS_CONFIG_ENV, configured_legacy_relay_env_vars, legacy_relay_plugin_keys,
|
||||
)
|
||||
|
||||
findings: list[tuple[str, str]] = []
|
||||
plugins = raw_config.get("plugins") if isinstance(raw_config, dict) else None
|
||||
if isinstance(plugins, dict):
|
||||
@@ -135,7 +131,6 @@ def _check_mcp_security(should_fix: bool, f: Finding) -> None:
|
||||
"""Flag mcp_servers entries with suspicious stdio commands."""
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.mcp_security import validate_mcp_server_entry
|
||||
|
||||
servers = load_config().get("mcp_servers") or {}
|
||||
suspicious = 0
|
||||
if isinstance(servers, dict):
|
||||
@@ -219,11 +214,10 @@ def _known_provider_ids(cfg: dict) -> tuple[set, list, object, object, object]:
|
||||
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))
|
||||
if aliases is not None:
|
||||
for entry in custom_providers:
|
||||
name = str(entry.get("name") or "").strip() if isinstance(entry, dict) else ""
|
||||
if name:
|
||||
known.update(aliases(name, str(entry.get("provider_key") or "").strip()))
|
||||
for entry in custom_providers if aliases is not None else ():
|
||||
name = str(entry.get("name") or "").strip() if isinstance(entry, dict) else ""
|
||||
if name:
|
||||
known.update(aliases(name, str(entry.get("provider_key") or "").strip()))
|
||||
return known, custom_providers, resolve_auth, normalize, resolve_full
|
||||
|
||||
|
||||
@@ -240,11 +234,8 @@ def _provider_has_credentials(runtime_provider: str) -> bool:
|
||||
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
|
||||
|
||||
return bool(str(get_env_value("OPENROUTER_API_KEY") or "").strip()
|
||||
or str(get_env_value("OPENAI_API_KEY") or "").strip())
|
||||
return any(str(get_env_value(k) or "").strip() for k in ("OPENROUTER_API_KEY", "OPENAI_API_KEY"))
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY, get_auth_status
|
||||
|
||||
pconfig = PROVIDER_REGISTRY.get(runtime_provider)
|
||||
if pconfig and getattr(pconfig, "auth_type", "") == "api_key":
|
||||
status = get_auth_status(runtime_provider) or {}
|
||||
@@ -264,12 +255,11 @@ def _validate_model_config(config_path, issues: list) -> None:
|
||||
known_providers, custom_providers, resolve_auth, normalize, resolve_full = _known_provider_ids(cfg)
|
||||
valid_provider_ids = set(known_providers)
|
||||
accept = {provider} if provider else set()
|
||||
if normalize is not None:
|
||||
for known_provider in known_providers:
|
||||
try:
|
||||
valid_provider_ids.add(normalize(known_provider))
|
||||
except Exception:
|
||||
continue
|
||||
for known_provider in known_providers if normalize is not None else ():
|
||||
try:
|
||||
valid_provider_ids.add(normalize(known_provider))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
runtime_provider = catalog_provider = provider
|
||||
if provider and provider not in {"auto", "custom"}:
|
||||
@@ -340,11 +330,10 @@ def _check_config_file(should_fix: bool) -> Finding:
|
||||
example_config = PROJECT_ROOT / 'cli-config.yaml.example'
|
||||
if example_config.exists():
|
||||
shutil.copy2(str(example_config), str(config_path))
|
||||
check_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)
|
||||
check_ok(f"Created {_DHH}/config.yaml from defaults")
|
||||
check_ok(f"Created {_DHH}/config.yaml from {'cli-config.yaml.example' if example_config.exists() else 'defaults'}")
|
||||
f.fixed += 1
|
||||
else:
|
||||
check_warn("config.yaml not found", "(using defaults)")
|
||||
@@ -481,7 +470,6 @@ def _check_config_drift(should_fix: bool) -> Finding:
|
||||
def _check_xai_retirement(should_fix: bool, f: Finding) -> None:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.xai_retirement import MIGRATION_GUIDE_URL, find_retired_xai_refs, format_issue
|
||||
|
||||
retired_refs = find_retired_xai_refs(load_config())
|
||||
if not retired_refs:
|
||||
check_ok("No retired xAI models in config")
|
||||
|
||||
@@ -189,26 +189,7 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_heal
|
||||
return _row(pname, "ok", "(key configured)", label=label)
|
||||
try:
|
||||
import httpx
|
||||
base = os.getenv(base_env, "") if base_env else ""
|
||||
# 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 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)
|
||||
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"
|
||||
# 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
|
||||
base, url, headers = _apikey_request(key, base_env, default_url)
|
||||
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)
|
||||
@@ -221,6 +202,31 @@ def _probe_apikey_provider(pname, env_vars, default_url, base_env, supports_heal
|
||||
return _row(pname, "warn", f"(HTTP {r.status_code})", label=label)
|
||||
|
||||
|
||||
def _apikey_request(key: str, base_env, default_url) -> tuple:
|
||||
"""(effective base, models URL, headers) for a generic Bearer-auth probe, with the per-vendor rewrites."""
|
||||
base = os.getenv(base_env, "") if base_env else ""
|
||||
# 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 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)
|
||||
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"
|
||||
# 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
|
||||
return base, url, headers
|
||||
|
||||
|
||||
def _probe_bedrock() -> ProbeResult:
|
||||
name = "AWS Bedrock"
|
||||
try:
|
||||
@@ -263,8 +269,7 @@ def _probe_azure_entra() -> ProbeResult:
|
||||
model_cfg = cfg.get("model") if isinstance(cfg, dict) else {}
|
||||
if not isinstance(model_cfg, dict):
|
||||
return _skip(name)
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
auth_mode = str(model_cfg.get("auth_mode") or "").strip().lower()
|
||||
cfg_provider, auth_mode = (str(model_cfg.get(k) or "").strip().lower() for k in ("provider", "auth_mode"))
|
||||
if cfg_provider != "azure-foundry" or auth_mode != "entra_id":
|
||||
return _skip(name)
|
||||
except Exception:
|
||||
@@ -278,9 +283,7 @@ def _probe_azure_entra() -> ProbeResult:
|
||||
return _row(name, "warn", f"(adapter import failed: {exc})", [f"Azure Foundry adapter import failed: {exc}"], label=label)
|
||||
|
||||
if not has_azure_identity_installed():
|
||||
return _row(name, "warn", "(azure-identity not installed)",
|
||||
[f"Install azure-identity: {sys.executable} -m pip install azure-identity"], label=label)
|
||||
|
||||
return _row(name, "warn", "(azure-identity not installed)", [f"Install azure-identity: {sys.executable} -m pip install azure-identity"], label=label)
|
||||
entra_cfg = model_cfg.get("entra") or {}
|
||||
scope = (str(entra_cfg.get("scope") or "").strip() if isinstance(entra_cfg, dict) else "") or SCOPE_AI_AZURE_DEFAULT
|
||||
info = describe_active_credential(config=EntraIdentityConfig(scope=scope), timeout_seconds=10.0)
|
||||
|
||||
@@ -42,7 +42,6 @@ class ProbeResult:
|
||||
def _load_config() -> dict:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
return load_config() or {}
|
||||
except Exception:
|
||||
return {}
|
||||
@@ -51,19 +50,16 @@ def _load_config() -> dict:
|
||||
def _http_get(url: str, headers: Optional[dict] = None, timeout: Optional[float] = None):
|
||||
"""Single HTTP GET seam for all metadata probes."""
|
||||
import httpx
|
||||
|
||||
return httpx.get(url, headers=headers or {}, timeout=timeout)
|
||||
|
||||
|
||||
def _browser_available() -> bool:
|
||||
"""Is the local browser automation backend (agent-browser) installed?"""
|
||||
import shutil
|
||||
|
||||
if shutil.which("agent-browser"):
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.doctor import HERMES_HOME, PROJECT_ROOT
|
||||
|
||||
if (PROJECT_ROOT / "node_modules" / "agent-browser").exists():
|
||||
return True
|
||||
for candidate in (HERMES_HOME / "node" / "bin", HERMES_HOME / "node", HERMES_HOME / "node_modules" / ".bin"):
|
||||
@@ -75,11 +71,7 @@ def _browser_available() -> bool:
|
||||
# probes above. Mirror the rung hermes_cli.doctor uses so this probe can't diverge from it, including
|
||||
# the Termux carve-out (bare npx is too fragile to advertise as ready there).
|
||||
try:
|
||||
from tools.browser_tool import (
|
||||
_find_agent_browser,
|
||||
_is_npx_agent_browser_sentinel,
|
||||
_requires_real_termux_browser_install,
|
||||
)
|
||||
from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install
|
||||
browser_cmd = _find_agent_browser(validate=False)
|
||||
except Exception:
|
||||
return False
|
||||
@@ -93,7 +85,6 @@ def _launch_browser_probe(timeout: float) -> tuple:
|
||||
from playwright.sync_api import sync_playwright
|
||||
except ImportError:
|
||||
return (False, "playwright not installed")
|
||||
|
||||
with sync_playwright() as p:
|
||||
browser = p.chromium.launch(headless=True, timeout=timeout * 1000)
|
||||
try:
|
||||
@@ -106,7 +97,6 @@ def _launch_browser_probe(timeout: float) -> tuple:
|
||||
def _probe_mcp_server(name: str, config: dict, timeout: float):
|
||||
"""initialize + tools/list against one configured MCP server."""
|
||||
from hermes_cli.mcp_config import _probe_single_server
|
||||
|
||||
return _probe_single_server(name, config, connect_timeout=timeout)
|
||||
|
||||
|
||||
|
||||
@@ -32,14 +32,11 @@ def _sqlite_upgrade_hint(install_method: str | None = None) -> str:
|
||||
"""Return an actionable SQLite upgrade hint for this install layout."""
|
||||
from hermes_cli.doctor import PROJECT_ROOT, detect_install_method
|
||||
method = install_method or detect_install_method(PROJECT_ROOT)
|
||||
if method == "docker":
|
||||
action = f"run `{recommended_update_command_for_method(method)}`, then recreate all Hermes containers"
|
||||
elif is_nix_install_method(method):
|
||||
action = recommended_update_command_for_method(method) # prose guidance, not a shell command
|
||||
elif method == "apt":
|
||||
action = f"run `{recommended_update_command_for_method(method)}`"
|
||||
cmd = recommended_update_command_for_method(method)
|
||||
if is_nix_install_method(method):
|
||||
action = cmd # prose guidance, not a shell command
|
||||
else:
|
||||
action = "run `hermes update`"
|
||||
action = {"docker": f"run `{cmd}`, then recreate all Hermes containers", "apt": f"run `{cmd}`"}.get(method, "run `hermes update`")
|
||||
return f"({action}; fixed versions: 3.51.3+ / 3.50.7 / 3.44.6 — see https://sqlite.org/wal.html#walresetbug)"
|
||||
|
||||
|
||||
@@ -116,19 +113,17 @@ def _report_database_journal_modes(hermes_home: Path | None = None, version_info
|
||||
continue
|
||||
mode, error = _read_journal_mode(path)
|
||||
size = _format_db_size(path)
|
||||
if error is not None:
|
||||
if vulnerable:
|
||||
check_warn(f"{name}: journal mode could not be read", f"({error}; cannot rule out WAL exposure)")
|
||||
else:
|
||||
check_info(f"{name}: journal mode could not be read ({error})")
|
||||
if error is not None and vulnerable:
|
||||
check_warn(f"{name}: journal mode could not be read", f"({error}; cannot rule out WAL exposure)")
|
||||
elif error is not None:
|
||||
check_info(f"{name}: journal mode could not be read ({error})")
|
||||
elif mode == "wal" and vulnerable:
|
||||
exposed.append(name)
|
||||
check_warn(f"{name} is in WAL mode ({size})", "(exposed to the WAL-reset bug until SQLite is upgraded)")
|
||||
elif mode == "wal":
|
||||
if vulnerable:
|
||||
exposed.append(name)
|
||||
check_warn(f"{name} is in WAL mode ({size})", "(exposed to the WAL-reset bug until SQLite is upgraded)")
|
||||
else:
|
||||
check_info(f"{name}: WAL journal mode ({size})")
|
||||
check_info(f"{name}: WAL journal mode ({size})")
|
||||
else:
|
||||
check_info(f"{name}: rollback journal mode ({size}, not exposed)" if vulnerable else f"{name}: rollback journal mode ({size})")
|
||||
check_info(f"{name}: rollback journal mode ({size}{', not exposed' if vulnerable else ''})")
|
||||
if exposed:
|
||||
check_info(f"To clear the exposure: {_wal_reset_repair_hint()}")
|
||||
|
||||
@@ -188,8 +183,7 @@ def _check_s6_supervision(issues: list[str]) -> None:
|
||||
|
||||
profiles = mgr.list_profile_gateways()
|
||||
if not profiles:
|
||||
check_info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
|
||||
return
|
||||
return check_info("No per-profile gateways registered yet — create one with `hermes profile create <name>`")
|
||||
up_count = sum(1 for p in profiles if mgr.is_running(f"gateway-{p}"))
|
||||
check_ok(f"Per-profile gateways: {up_count}/{len(profiles)} supervised up"
|
||||
+ (f" ({', '.join(sorted(profiles))})" if len(profiles) <= 8 else ""))
|
||||
@@ -207,11 +201,8 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
|
||||
except Exception as e:
|
||||
check_warn("SSL certificate check skipped", str(e))
|
||||
return
|
||||
|
||||
def add_issue(msg: str) -> None:
|
||||
if issues is not None:
|
||||
issues.append(msg)
|
||||
|
||||
if issues is None:
|
||||
issues = []
|
||||
try:
|
||||
verify_ca_bundle_with_fallback()
|
||||
check_ok("SSL CA certificate bundle is valid")
|
||||
@@ -225,20 +216,19 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
|
||||
check_fail("SSL CA certificate bundle is broken", first_error)
|
||||
pip_cmd = f"{sys.executable} -m pip install --force-reinstall certifi"
|
||||
if not should_fix:
|
||||
add_issue(f"Repair the CA bundle: run `hermes doctor --fix`, or `{pip_cmd}`")
|
||||
issues.append(f"Repair the CA bundle: run `hermes doctor --fix`, or `{pip_cmd}`")
|
||||
return
|
||||
|
||||
print(" → Repairing: force-reinstalling certifi...")
|
||||
try:
|
||||
result = subprocess.run([sys.executable, "-m", "pip", "install", "--force-reinstall", "certifi"],
|
||||
capture_output=True, text=True, timeout=300)
|
||||
failure = ("certifi reinstall failed", (result.stderr or result.stdout or "")[-500:]) if result.returncode != 0 else None
|
||||
except Exception as exc:
|
||||
check_fail("certifi repair could not run pip", str(exc))
|
||||
add_issue(f"Reinstall certifi manually: {pip_cmd}")
|
||||
return
|
||||
if result.returncode != 0:
|
||||
check_fail("certifi reinstall failed", (result.stderr or result.stdout or "")[-500:])
|
||||
add_issue(f"Reinstall certifi manually: {pip_cmd}")
|
||||
failure = ("certifi repair could not run pip", str(exc))
|
||||
if failure:
|
||||
check_fail(*failure)
|
||||
issues.append(f"Reinstall certifi manually: {pip_cmd}")
|
||||
return
|
||||
|
||||
# Drop cached certifi modules so where() resolves the fresh install without a restart.
|
||||
@@ -252,11 +242,8 @@ def check_certificates(should_fix: bool = False, issues: "list | None" = None) -
|
||||
check_ok("SSL CA certificate bundle repaired (certifi reinstalled)")
|
||||
except SSLConfigurationError as e:
|
||||
check_fail("SSL CA certificate bundle still broken after reinstall", str(e))
|
||||
add_issue(
|
||||
"certifi reinstall did not restore the CA bundle — check for a "
|
||||
"custom CA env var (SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing "
|
||||
"at a missing file, or recreate the venv."
|
||||
)
|
||||
issues.append("certifi reinstall did not restore the CA bundle — check for a custom CA env var "
|
||||
"(SSL_CERT_FILE/REQUESTS_CA_BUNDLE) pointing at a missing file, or recreate the venv.")
|
||||
|
||||
|
||||
def _check_gateway_service_linger(issues: list[str]) -> None:
|
||||
@@ -291,9 +278,7 @@ def check_macos_tcc_grants() -> None:
|
||||
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":
|
||||
return
|
||||
app = _desktop_app_bundle()
|
||||
app = _desktop_app_bundle() if sys.platform == "darwin" else None
|
||||
if app is None:
|
||||
return
|
||||
dr = _macos_desktop_dr(app)
|
||||
@@ -335,13 +320,11 @@ def _desktop_app_bundle() -> Path | None:
|
||||
def _macos_desktop_dr(app: Path) -> str | None:
|
||||
"""Return the bundle's designated requirement string, or None on failure (a hanging codesign must never abort doctor)."""
|
||||
codesign = shutil.which("codesign")
|
||||
if not codesign:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run([codesign, "-d", "--requirements", "-", str(app)], capture_output=True, text=True, timeout=15)
|
||||
proc = subprocess.run([codesign, "-d", "--requirements", "-", str(app)], capture_output=True, text=True, timeout=15) if codesign else None
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
return None if proc.returncode != 0 else (proc.stdout or "") + (proc.stderr or "")
|
||||
return None if proc is None or proc.returncode != 0 else (proc.stdout or "") + (proc.stderr or "")
|
||||
|
||||
|
||||
def check_macos_tcc_anchor(should_fix: bool = False) -> None:
|
||||
@@ -354,13 +337,10 @@ def check_macos_tcc_anchor(should_fix: bool = False) -> None:
|
||||
if status == "skip":
|
||||
return
|
||||
if status == "active":
|
||||
check_ok("macOS TCC anchor active", f"({detail})")
|
||||
return
|
||||
if should_fix:
|
||||
anchored = tcc.ensure_tcc_anchor()
|
||||
if anchored is not None:
|
||||
check_ok("macOS TCC anchor installed", f"({anchored})")
|
||||
return
|
||||
return check_ok("macOS TCC anchor active", f"({detail})")
|
||||
anchored = tcc.ensure_tcc_anchor() if should_fix else None
|
||||
if anchored is not None:
|
||||
return check_ok("macOS TCC anchor installed", f"({anchored})")
|
||||
check_warn("macOS TCC anchor missing" if status == "missing" else "macOS TCC anchor stale", f"({detail})")
|
||||
except Exception as e:
|
||||
check_warn("macOS TCC anchor check failed", f"({e})")
|
||||
@@ -377,7 +357,9 @@ def check_macos_full_disk_access() -> None:
|
||||
tcc_dir = Path.home() / "Library" / "Application Support" / "com.apple.TCC"
|
||||
try:
|
||||
os.listdir(tcc_dir)
|
||||
except PermissionError:
|
||||
except OSError as e:
|
||||
if not isinstance(e, PermissionError):
|
||||
return
|
||||
check_info(
|
||||
"One switch silences all macOS folder prompts: grant your terminal "
|
||||
"app Full Disk Access and Hermes will never trip per-folder dialogs "
|
||||
@@ -389,8 +371,6 @@ def check_macos_full_disk_access() -> None:
|
||||
"and restart them once. With Hermes' stable signing identities the "
|
||||
"grant survives every update."
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
else:
|
||||
check_ok("macOS Full Disk Access granted", "(no per-folder permission prompts will occur)")
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ def _mark(glyph: str, col: str):
|
||||
|
||||
|
||||
check_ok, check_warn, check_fail = _mark("✓", Colors.GREEN), _mark("⚠", Colors.YELLOW), _mark("✗", Colors.RED)
|
||||
CHECK_ROW = {"ok": check_ok, "warn": check_warn, "fail": check_fail}
|
||||
|
||||
|
||||
def check_info(text: str):
|
||||
@@ -24,13 +23,9 @@ def check_info(text: str):
|
||||
|
||||
def check_bool(cond, ok, bad, *, fail: bool = False):
|
||||
"""``check_ok(*ok)`` when *cond* else ``check_warn(*bad)`` (``check_fail`` with fail=True); returns bool(cond).
|
||||
|
||||
*ok* / *bad* are a text string or a ``(text, detail)`` tuple.
|
||||
"""
|
||||
*ok* / *bad* are a text string or a ``(text, detail)`` tuple."""
|
||||
args = ok if cond else bad
|
||||
if isinstance(args, str):
|
||||
args = (args,)
|
||||
(check_ok if cond else (check_fail if fail else check_warn))(*args)
|
||||
(check_ok if cond else (check_fail if fail else check_warn))(*((args,) if isinstance(args, str) else args))
|
||||
return bool(cond)
|
||||
|
||||
|
||||
@@ -62,11 +57,8 @@ class Finding:
|
||||
|
||||
def doctor_check(on_error: str | None = None, detail: str = ""):
|
||||
"""Turn ``fn(should_fix, f: Finding)`` into a ``(should_fix) -> Finding`` doctor check.
|
||||
|
||||
Creates the Finding, and if *fn* raises prints ``check_warn(on_error.format(e=e), detail.format(e=e))``
|
||||
(nothing when *on_error* is None) — the partial Finding is still returned, so issues recorded
|
||||
before the crash survive.
|
||||
"""
|
||||
If *fn* raises, prints ``check_warn(on_error.format(e=e), detail.format(e=e))`` (nothing when *on_error*
|
||||
is None); the partial Finding is still returned, so issues recorded before the crash survive."""
|
||||
def deco(fn):
|
||||
@functools.wraps(fn)
|
||||
def check(should_fix: bool) -> Finding:
|
||||
|
||||
@@ -18,7 +18,6 @@ def _honcho_is_configured_for_doctor() -> bool:
|
||||
"""Return True when Honcho is configured, even if this process has no active session."""
|
||||
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:
|
||||
@@ -30,14 +29,12 @@ def _doctor_memory_config(hermes_home: Path | None = None) -> dict:
|
||||
from hermes_cli.doctor import HERMES_HOME
|
||||
try:
|
||||
from hermes_cli.config import _expand_env_vars, read_user_config_raw
|
||||
|
||||
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))
|
||||
try:
|
||||
from hermes_cli import managed_scope
|
||||
|
||||
config = managed_scope.apply_managed_overlay(config)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -52,6 +49,11 @@ def _doctor_memory_config(hermes_home: Path | None = None) -> dict:
|
||||
STATE_DB_SIZE_WARN_BYTES = 1 * 1024 * 1024 * 1024 # 1 GiB logical size
|
||||
|
||||
|
||||
def _bits(*pairs) -> list:
|
||||
"""``[fmt() for value, fmt in pairs if value is not None]`` — present-only stat fragments."""
|
||||
return [fmt() for value, fmt in pairs if value is not None]
|
||||
|
||||
|
||||
def _render_state_db_stats(stats: dict, holders=None) -> list:
|
||||
"""Turn a collect_state_db_stats() dict into ``(kind, text, detail)`` rows, kind 'info' / 'warn'.
|
||||
|
||||
@@ -64,27 +66,20 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
|
||||
wal = stats.get("wal_size_bytes")
|
||||
freelist = stats.get("freelist_count")
|
||||
|
||||
size_bits = []
|
||||
if logical is not None:
|
||||
size_bits.append(f"logical size {_human_bytes(logical)}")
|
||||
if stats.get("page_count") is not None:
|
||||
size_bits.append(f"{stats['page_count']:,} pages")
|
||||
if freelist is not None:
|
||||
size_bits.append(f"{freelist:,} free")
|
||||
if wal is not None:
|
||||
size_bits.append(f"WAL {_human_bytes(wal)}")
|
||||
size_bits = _bits(
|
||||
(logical, lambda: f"logical size {_human_bytes(logical)}"),
|
||||
(stats.get("page_count"), lambda: f"{stats['page_count']:,} pages"),
|
||||
(freelist, lambda: f"{freelist:,} free"),
|
||||
(wal, lambda: f"WAL {_human_bytes(wal)}"),
|
||||
)
|
||||
if size_bits:
|
||||
lines.append(("info", "state.db " + ", ".join(size_bits), ""))
|
||||
|
||||
row_bits = []
|
||||
if stats.get("messages") is not None:
|
||||
row_bits.append(f"{stats['messages']:,} messages")
|
||||
if stats.get("sessions") is not None:
|
||||
row_bits.append(f"{stats['sessions']:,} sessions")
|
||||
if stats.get("journal_mode"):
|
||||
row_bits.append(f"journal_mode={stats['journal_mode']}")
|
||||
if holders is not None:
|
||||
row_bits.append(f"{holders} process(es) holding the DB open")
|
||||
row_bits = _bits(
|
||||
(stats.get("messages"), lambda: f"{stats['messages']:,} messages"),
|
||||
(stats.get("sessions"), lambda: f"{stats['sessions']:,} sessions"),
|
||||
(stats.get("journal_mode") or None, lambda: f"journal_mode={stats['journal_mode']}"),
|
||||
(holders, lambda: f"{holders} process(es) holding the DB open"),
|
||||
)
|
||||
if row_bits:
|
||||
lines.append(("info", ", ".join(row_bits), ""))
|
||||
|
||||
@@ -120,7 +115,6 @@ def _render_state_db_stats(stats: dict, holders=None) -> list:
|
||||
|
||||
def _memory_store_flags(hermes_home: Path) -> tuple:
|
||||
from tools.memory_tool import get_builtin_memory_store_flags
|
||||
|
||||
return get_builtin_memory_store_flags({"memory": _doctor_memory_config(hermes_home)})
|
||||
|
||||
|
||||
@@ -226,7 +220,6 @@ def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: st
|
||||
# `SELECT COUNT(*)` succeeds even when the FTS index is corrupt and every message write
|
||||
# fails through the triggers; _db_opens_cleanly drives a rolled-back write to surface that.
|
||||
from hermes_state import _db_opens_cleanly
|
||||
|
||||
_write_reason = _db_opens_cleanly(state_db_path)
|
||||
if _write_reason is not None:
|
||||
check_warn(f"{_DHH}/state.db fails a write-health probe (FTS index may be corrupt)", f"({_write_reason})")
|
||||
@@ -241,7 +234,6 @@ def _state_db_health(f: Finding, should_fix: bool, state_db_path: Path, _DHH: st
|
||||
)
|
||||
except Exception as e:
|
||||
from hermes_state import is_malformed_db_error
|
||||
|
||||
if not is_malformed_db_error(e):
|
||||
check_warn(f"{_DHH}/state.db exists but has issues: {e}")
|
||||
return
|
||||
@@ -264,7 +256,6 @@ def _state_db_stats(issues: list, state_db_path: Path) -> None:
|
||||
the gateway; any failure degrades to one info line rather than failing doctor."""
|
||||
try:
|
||||
from hermes_state import collect_state_db_stats, count_db_holders
|
||||
|
||||
rows = _render_state_db_stats(collect_state_db_stats(state_db_path), holders=count_db_holders(state_db_path))
|
||||
for _kind, _text, _detail in rows:
|
||||
if _kind != "warn":
|
||||
@@ -351,7 +342,6 @@ def _check_skills_hub(should_fix: bool) -> Finding:
|
||||
check_warn(f"{q_count} skill(s) in quarantine", "(pending review)")
|
||||
|
||||
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)")
|
||||
else:
|
||||
@@ -440,7 +430,6 @@ def _check_memory_provider(should_fix: bool) -> Finding:
|
||||
def _check_profiles(should_fix: bool, f: Finding) -> None:
|
||||
from hermes_cli.profiles import list_profiles, _get_wrapper_dir, profile_exists
|
||||
import re as _re
|
||||
|
||||
named_profiles = [p for p in list_profiles() if not p.is_default]
|
||||
if not named_profiles:
|
||||
return
|
||||
|
||||
@@ -11,7 +11,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from hermes_cli.doctor_platform import _system_package_install_cmd
|
||||
from hermes_cli.doctor_report import CHECK_ROW, Finding, _fail_and_issue, check_bool, check_info, check_ok, check_warn, doctor_check
|
||||
from hermes_cli.doctor_report import Finding, _fail_and_issue, check_bool, check_info, check_ok, check_warn, doctor_check
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_constants import agent_browser_runnable, is_termux as _is_termux
|
||||
|
||||
@@ -71,12 +71,10 @@ def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
|
||||
try:
|
||||
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
|
||||
|
||||
# Fresh process: bundled web providers only register during plugin discovery (idempotent, cheap).
|
||||
_ensure_web_plugins_loaded()
|
||||
except Exception:
|
||||
return rows
|
||||
|
||||
for capability, getter in (("web search", get_active_search_provider), ("web extract", get_active_extract_provider)):
|
||||
try:
|
||||
provider = getter()
|
||||
@@ -94,8 +92,7 @@ def _doctor_web_capability_rows() -> list[tuple[str, str, str]]:
|
||||
def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]:
|
||||
"""Adjust runtime-gated tool availability for doctor diagnostics."""
|
||||
from hermes_cli.doctor import _honcho_is_configured_for_doctor
|
||||
updated_available = list(available)
|
||||
updated_unavailable = []
|
||||
updated_available, updated_unavailable = list(available), []
|
||||
for item in unavailable:
|
||||
if _is_kanban_worker_env_gate(item):
|
||||
gated = "kanban"
|
||||
@@ -220,10 +217,8 @@ def _check_vercel_backend(issues: list[str]) -> None:
|
||||
def _check_plugin_backend(terminal_env: str, issues: list[str]) -> None:
|
||||
try:
|
||||
from hermes_cli.plugins import discover_plugins
|
||||
|
||||
discover_plugins()
|
||||
from agent.terminal_env_registry import get_provider
|
||||
|
||||
provider = get_provider(terminal_env)
|
||||
except Exception:
|
||||
provider = None
|
||||
@@ -247,7 +242,6 @@ def _check_terminal_backend(should_fix: bool) -> Finding:
|
||||
running_in_container = _is_container()
|
||||
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 (mounted docker.sock) still gets checked.
|
||||
if running_in_container and terminal_env != "docker":
|
||||
@@ -460,7 +454,7 @@ def _check_tool_availability(should_fix: bool, f: Finding) -> None:
|
||||
for tid in available:
|
||||
check_ok(TOOLSET_REQUIREMENTS.get(tid, {}).get("name", tid), _doctor_tool_availability_detail(tid))
|
||||
for status, label, detail in web_rows:
|
||||
CHECK_ROW[status](label, detail)
|
||||
(check_ok if status == "ok" else check_warn)(label, detail)
|
||||
for item in unavailable:
|
||||
env_vars = item.get("missing_vars") or item.get("env_vars") or []
|
||||
check_warn(item["name"], f"(missing {', '.join(env_vars)})" if env_vars else "(system dependency not met)")
|
||||
|
||||
@@ -76,7 +76,6 @@ def _gateway_status() -> str:
|
||||
"""Return a short gateway status string."""
|
||||
try:
|
||||
from hermes_cli.gateway import get_gateway_runtime_snapshot
|
||||
|
||||
snapshot = get_gateway_runtime_snapshot()
|
||||
if snapshot.running:
|
||||
mode = "manual" if snapshot.has_process_service_mismatch else snapshot.manager
|
||||
@@ -142,7 +141,6 @@ _INTERESTING_PATHS = (
|
||||
def _config_overrides(config: dict) -> dict[str, str]:
|
||||
"""Find non-default config values worth reporting."""
|
||||
from hermes_cli.config import DEFAULT_CONFIG
|
||||
|
||||
overrides = {}
|
||||
for section, key in _INTERESTING_PATHS:
|
||||
default_section = DEFAULT_CONFIG.get(section, {})
|
||||
@@ -214,7 +212,6 @@ def _api_key_lines(show_keys: bool) -> list[str]:
|
||||
if not val and label == "openrouter":
|
||||
try:
|
||||
from agent.credential_pool import load_pool as _load_pool
|
||||
|
||||
if _load_pool("openrouter").has_credentials():
|
||||
display = "set (auth pool)"
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user