feat(cli): add hermes status --short
Compact, secret-free summary of the component status: model, provider, connected providers, gateway, messaging platforms, and scheduled jobs. The provider list is registry-driven (PROVIDER_REGISTRY API-key rows, refresh-free OAuth getters, plugin-mirrored OAuth providers, the OpenRouter rung, configured custom endpoints), so new providers and endpoints surface without touching the short renderer. Plugin-platform configuredness prefers the is_connected hook over the deps probe (same rule as hermes setup and the #102183 fix); every lookup is fail-soft, so a failing probe degrades one row, never the command. Full `hermes status` output and the console_engine call shape are unchanged. Docs: website/docs/reference/cli-commands.md. Tests: 8 new cases in tests/hermes_cli/test_status.py (25/25 pass via run_tests.sh).
This commit is contained in:
@@ -17,8 +17,9 @@ from hermes_cli.config_defaults import DEFAULT_SANDBOX_IMAGE, DEFAULT_VERCEL_IMA
|
||||
from hermes_cli.models import provider_label
|
||||
from hermes_cli.runtime_provider import resolve_requested_provider
|
||||
from hermes_cli.vercel_auth import describe_vercel_auth
|
||||
from hermes_cli.status_auth import ( # renderers wired into _SECTIONS below
|
||||
_render_api_keys, _render_apikey_providers, _render_auth_providers, _render_nous_gateway)
|
||||
from hermes_cli.status_auth import ( # section renderers + the --short provider list
|
||||
_connected_provider_labels, _render_api_keys, _render_apikey_providers,
|
||||
_render_auth_providers, _render_nous_gateway)
|
||||
from hermes_constants import OPENROUTER_MODELS_URL
|
||||
|
||||
|
||||
@@ -144,15 +145,20 @@ def _render_header(ctx):
|
||||
_banner((paused,), Colors.YELLOW, Colors.BOLD)
|
||||
|
||||
|
||||
def _load_ctx_config(ctx) -> None:
|
||||
"""Load config.yaml into the shared context (fail-soft)."""
|
||||
try:
|
||||
ctx.config = load_config()
|
||||
except Exception:
|
||||
ctx.config = {}
|
||||
|
||||
|
||||
def _render_environment(ctx):
|
||||
_section("Environment")
|
||||
_kv("Project:", PROJECT_ROOT)
|
||||
_kv("Python:", sys.version.split()[0])
|
||||
_kv_flag(".env file:", get_env_path().exists(), "exists", "not found")
|
||||
try:
|
||||
ctx.config = load_config()
|
||||
except Exception:
|
||||
ctx.config = {}
|
||||
_load_ctx_config(ctx)
|
||||
_kv("Model:", _configured_model_label(ctx.config))
|
||||
_kv("Provider:", _effective_provider_label())
|
||||
|
||||
@@ -255,19 +261,23 @@ def _load_json(path: Path, encoding: str = "utf-8"):
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _render_cron(ctx):
|
||||
_section("Scheduled Jobs")
|
||||
def _cron_summary() -> str:
|
||||
"""``N active, M total`` jobs line, shared by the full and short status renderers."""
|
||||
jobs_file = get_hermes_home() / "cron" / "jobs.json"
|
||||
if not jobs_file.exists():
|
||||
_kv("Jobs:", 0)
|
||||
return
|
||||
return "0"
|
||||
try:
|
||||
# utf-8-sig: same dialect as cron/jobs.load_jobs — Windows editors may leave a UTF-8 BOM
|
||||
# that plain utf-8 json.load rejects.
|
||||
jobs = _load_json(jobs_file, "utf-8-sig").get("jobs", [])
|
||||
_kv("Jobs:", f"{sum(1 for j in jobs if j.get('enabled', True))} active, {len(jobs)} total")
|
||||
return f"{sum(1 for j in jobs if j.get('enabled', True))} active, {len(jobs)} total"
|
||||
except Exception:
|
||||
_kv("Jobs:", "(error reading jobs file)")
|
||||
return "(error reading jobs file)"
|
||||
|
||||
|
||||
def _render_cron(ctx):
|
||||
_section("Scheduled Jobs")
|
||||
_kv("Jobs:", _cron_summary())
|
||||
|
||||
|
||||
def _render_sessions(ctx):
|
||||
@@ -351,6 +361,77 @@ def _render_footer(ctx):
|
||||
print()
|
||||
|
||||
|
||||
def _short_gateway_value():
|
||||
"""``(running, text)`` for the short gateway row; ``(None, "unknown")`` when unavailable."""
|
||||
try:
|
||||
from hermes_cli.gateway import (
|
||||
get_gateway_runtime_snapshot, named_profile_served_by_running_multiplexer)
|
||||
snapshot = get_gateway_runtime_snapshot()
|
||||
# A satellite profile has no gateway.pid of its own; the default multiplexer is
|
||||
# its live process (same rule as the full Gateway Service section).
|
||||
if not snapshot.running and named_profile_served_by_running_multiplexer():
|
||||
return True, "running (via the default-profile multiplexer)"
|
||||
return (True, "running") if snapshot.running else (False, "stopped")
|
||||
except Exception:
|
||||
return None, "unknown"
|
||||
|
||||
|
||||
def _short_platform_configured(entry) -> bool:
|
||||
"""Plugin platform configured? Prefer the ``is_connected`` hook over the deps probe.
|
||||
|
||||
``check_fn`` is a passive dependency probe that reads True for every bundled plugin,
|
||||
so it must never override a configured-credentials verdict (same rule as ``hermes
|
||||
setup`` and the #102183 fix).
|
||||
"""
|
||||
try:
|
||||
if entry.is_connected is not None:
|
||||
from gateway.config import PlatformConfig
|
||||
return bool(entry.is_connected(PlatformConfig(enabled=True)))
|
||||
return bool(entry.check_fn())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _short_platform_names() -> list:
|
||||
"""Configured messaging platform names: env table plus plugin-registry entries."""
|
||||
names = []
|
||||
for name, (token_var, _home_var) in _PLATFORMS.items():
|
||||
if os.getenv(token_var, ""):
|
||||
names.append(name)
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
for entry in platform_registry.plugin_entries():
|
||||
if _short_platform_configured(entry):
|
||||
names.append(entry.label)
|
||||
except Exception:
|
||||
pass
|
||||
return names
|
||||
|
||||
|
||||
def _render_short(ctx):
|
||||
"""Compact ``hermes status --short``: model, providers, gateway, platforms, jobs."""
|
||||
_banner(("☤ Hermes Agent status (short)",), Colors.BOLD)
|
||||
paused = _estop_status_line()
|
||||
if paused:
|
||||
_banner((paused,), Colors.YELLOW, Colors.BOLD)
|
||||
_load_ctx_config(ctx)
|
||||
_kv("Model:", _configured_model_label(ctx.config))
|
||||
_kv("Provider:", _effective_provider_label())
|
||||
try:
|
||||
labels = _connected_provider_labels(ctx)
|
||||
except Exception:
|
||||
labels = []
|
||||
_kv("Providers:", ", ".join(dict.fromkeys(labels)) if labels else "none connected")
|
||||
running, gateway_text = _short_gateway_value()
|
||||
if running is None:
|
||||
_kv("Gateway:", gateway_text)
|
||||
else:
|
||||
_kv_flag("Gateway:", running, gateway_text, "stopped")
|
||||
names = _short_platform_names()
|
||||
_kv("Platforms:", ", ".join(dict.fromkeys(names)) if names else "none configured")
|
||||
_kv("Jobs:", _cron_summary())
|
||||
|
||||
|
||||
# Print order of `hermes status`; each renderer takes the shared _StatusContext.
|
||||
_SECTIONS = (
|
||||
_render_header, _render_environment, _render_api_keys, _render_auth_providers, _render_nous_gateway,
|
||||
@@ -364,5 +445,8 @@ def show_status(args):
|
||||
# for the later Nous Tool Gateway section.
|
||||
ctx = SimpleNamespace(deep=getattr(args, 'deep', False), config={}, nous_logged_in=False,
|
||||
nous_inference_present=False, nous_account_info=None)
|
||||
if getattr(args, 'short', False):
|
||||
_render_short(ctx)
|
||||
return
|
||||
for render in _SECTIONS:
|
||||
render(ctx)
|
||||
|
||||
@@ -202,4 +202,106 @@ def _render_apikey_providers(ctx):
|
||||
_status._row("LM Studio", ok, msg, 16, " ")
|
||||
|
||||
|
||||
def _env_has_usable_secret(name: str) -> bool:
|
||||
"""True when ``name`` resolves (.env preferred) to a non-placeholder credential."""
|
||||
from hermes_cli.auth import has_usable_secret
|
||||
from hermes_cli.config import get_env_value_prefer_dotenv
|
||||
value = get_env_value_prefer_dotenv(name) or ""
|
||||
return bool(value) and has_usable_secret(value)
|
||||
|
||||
|
||||
def _any_usable_env(names) -> bool:
|
||||
return any(_env_has_usable_secret(name) for name in names)
|
||||
|
||||
|
||||
def _connected_provider_labels(ctx) -> list[str]:
|
||||
"""Labels of providers with usable credentials, for the compact ``--short`` summary.
|
||||
|
||||
Registry-driven so new API-key rows, OAuth blocks, plugin providers and custom
|
||||
endpoints surface without touching the short renderer. Read-only and cheap by
|
||||
design: env presence checks plus refresh-free auth getters (no subprocesses, no
|
||||
network, no model discovery).
|
||||
"""
|
||||
labels: list[str] = []
|
||||
import hermes_cli.auth as auth
|
||||
|
||||
# Nous Portal: the refresh-free local snapshot, same as the full Auth Providers section.
|
||||
try:
|
||||
nous_status = auth.get_nous_auth_status_local() or {}
|
||||
if nous_status.get("logged_in"):
|
||||
labels.append("Nous Portal")
|
||||
elif nous_status.get("free_tier"):
|
||||
labels.append("Nous Portal (free tier)")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Built-in OAuth rows: the same refresh-free getters the full section uses.
|
||||
for name, getter, _hint, _rows in _OAUTH_BLOCKS:
|
||||
try:
|
||||
status = getattr(auth, getter)() or {}
|
||||
except Exception:
|
||||
continue
|
||||
if status.get("logged_in"):
|
||||
labels.append(name)
|
||||
|
||||
# Plugin-mirrored OAuth providers: their status lives in the credential pool.
|
||||
try:
|
||||
import hermes_cli.auth_plugin_providers as plugin_providers
|
||||
for pid, pconfig in list(auth.PROVIDER_REGISTRY.items()):
|
||||
if pid not in plugin_providers.PLUGIN_MIRRORED_PROVIDERS:
|
||||
continue
|
||||
if not str(pconfig.auth_type or "").startswith("oauth"):
|
||||
continue
|
||||
try:
|
||||
status = plugin_providers.get_plugin_oauth_auth_status(pid) or {}
|
||||
except Exception:
|
||||
continue
|
||||
if status.get("logged_in"):
|
||||
labels.append(pconfig.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# API-key inference providers: registry rows, deduped by ``ProviderConfig.id``
|
||||
# (alias keys share the row object). ``copilot`` is skipped: its cheap env vars are
|
||||
# misleading, the real check runs the copilot_auth token path, and the full status
|
||||
# never shows it either.
|
||||
try:
|
||||
seen_ids = set()
|
||||
model_cfg = ctx.config.get("model") if isinstance(ctx.config.get("model"), dict) else {}
|
||||
pointer_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
pointer_key_env = str(model_cfg.get("key_env") or model_cfg.get("api_key_env") or "").strip()
|
||||
for pconfig in list(auth.PROVIDER_REGISTRY.values()):
|
||||
if pconfig.id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(pconfig.id)
|
||||
if pconfig.auth_type != "api_key" or pconfig.id == "copilot" or not pconfig.api_key_env_vars:
|
||||
continue
|
||||
env_vars = list(pconfig.api_key_env_vars)
|
||||
if pointer_key_env and pointer_provider == pconfig.id:
|
||||
env_vars.append(pointer_key_env)
|
||||
if _any_usable_env(env_vars):
|
||||
labels.append(pconfig.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# OpenRouter is deliberately outside PROVIDER_REGISTRY (#109397): its own rung.
|
||||
try:
|
||||
if _env_has_usable_secret("OPENROUTER_API_KEY"):
|
||||
labels.append("OpenRouter")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Configured custom endpoints (``providers:`` / legacy ``custom_providers:``).
|
||||
try:
|
||||
from hermes_cli.config import get_compatible_custom_providers
|
||||
for entry in get_compatible_custom_providers(ctx.config or None):
|
||||
name = str((entry or {}).get("name") or "").strip()
|
||||
if name:
|
||||
labels.append(name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
import hermes_cli.status as _status # noqa: E402 (bottom: hermes_cli.status imports this module)
|
||||
|
||||
@@ -10,6 +10,9 @@ def build_status_parser(subparsers, *, cmd_status: Callable) -> None:
|
||||
status_parser = subparsers.add_parser(
|
||||
"status", help="Show status of all components",
|
||||
description="Display status of Hermes Agent components")
|
||||
status_parser.add_argument(
|
||||
"--short", action="store_true",
|
||||
help="Compact summary (no secrets; safe to share)")
|
||||
status_parser.add_argument(
|
||||
"--all", action="store_true", help="Show all details (redacted for sharing)")
|
||||
status_parser.add_argument(
|
||||
|
||||
@@ -225,3 +225,132 @@ def test_show_status_reports_gateway_session_last_activity(monkeypatch, capsys,
|
||||
assert "Active: 2 session(s)" in output
|
||||
assert "Last activity:" in output
|
||||
assert "1m ago" in output
|
||||
|
||||
|
||||
# ── `hermes status --short` ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _short_env(monkeypatch, tmp_path):
|
||||
"""Quiet, hermetic environment for --short runs; returns (status_mod, auth_mod, gateway_mod)."""
|
||||
from hermes_cli import status as status_mod
|
||||
import hermes_cli.auth as auth_mod
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
monkeypatch.setattr(status_mod, "load_config", lambda: {"model": "test-model"}, raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_requested_provider", lambda requested=None: "test", raising=False)
|
||||
monkeypatch.setattr(status_mod, "resolve_provider", lambda requested=None, **kwargs: "test", raising=False)
|
||||
monkeypatch.setattr(status_mod, "provider_label", lambda provider: "Test Provider", raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_nous_auth_status_local", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_qwen_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_minimax_oauth_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(auth_mod, "get_xai_oauth_auth_status", lambda: {}, raising=False)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "get_gateway_runtime_snapshot",
|
||||
lambda: SimpleNamespace(running=False, manager="unknown", gateway_pids=[],
|
||||
has_process_service_mismatch=False, service_installed=False,
|
||||
service_running=False),
|
||||
raising=False)
|
||||
monkeypatch.setattr(gateway_mod, "named_profile_served_by_running_multiplexer", lambda: False, raising=False)
|
||||
return status_mod, auth_mod, gateway_mod
|
||||
|
||||
|
||||
def test_status_parser_accepts_short_flag():
|
||||
import argparse
|
||||
from hermes_cli.subcommands.status import build_status_parser
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers()
|
||||
build_status_parser(subparsers, cmd_status=lambda args: None)
|
||||
|
||||
assert parser.parse_args(["status", "--short"]).short is True
|
||||
assert parser.parse_args(["status"]).short is False
|
||||
both = parser.parse_args(["status", "--all", "--deep"])
|
||||
assert both.all is True and both.deep is True
|
||||
|
||||
|
||||
def test_short_reports_model_provider_and_connected_providers(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(auth_mod, "get_nous_auth_status_local", lambda: {"logged_in": True}, raising=False)
|
||||
monkeypatch.setenv("GLM_API_KEY", "glm-key-1234567890")
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
out = capsys.readouterr().out
|
||||
|
||||
assert "status (short)" in out
|
||||
assert "Model: test-model" in out
|
||||
assert "Provider: Test Provider" in out
|
||||
assert "Providers: Nous Portal, Z.AI / GLM" in out
|
||||
assert "none connected" not in out
|
||||
assert "hermes doctor" not in out and "hermes setup" not in out
|
||||
|
||||
|
||||
def test_short_new_registry_provider_appears_when_env_var_set(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
fake = auth_mod.ProviderConfig("acme", "Acme AI", "api_key", api_key_env_vars=("ACME_API_KEY",))
|
||||
monkeypatch.setattr(auth_mod, "PROVIDER_REGISTRY", {**auth_mod.PROVIDER_REGISTRY, "acme": fake}, raising=False)
|
||||
monkeypatch.delenv("ACME_API_KEY", raising=False)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
assert "Acme AI" not in capsys.readouterr().out
|
||||
|
||||
monkeypatch.setenv("ACME_API_KEY", "acme-key-1234567890")
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
assert "Acme AI" in capsys.readouterr().out
|
||||
|
||||
|
||||
def test_short_shows_none_connected_without_credentials(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(auth_mod, "PROVIDER_REGISTRY", {}, raising=False)
|
||||
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
out = capsys.readouterr().out
|
||||
assert "Providers: none connected" in out
|
||||
|
||||
|
||||
def test_short_does_not_print_key_values(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
sentinel = "NONSECRET_SENTINEL_VALUE_DO_NOT_PRINT_SHORT_123456"
|
||||
monkeypatch.setenv("GLM_API_KEY", sentinel)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
out = capsys.readouterr().out
|
||||
assert "Z.AI / GLM" in out
|
||||
assert sentinel not in out
|
||||
|
||||
|
||||
def test_short_gateway_row_reflects_snapshot(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, gateway_mod = _short_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(
|
||||
gateway_mod, "get_gateway_runtime_snapshot",
|
||||
lambda: SimpleNamespace(running=True, manager="systemd (user)", gateway_pids=[123],
|
||||
has_process_service_mismatch=False, service_installed=True,
|
||||
service_running=True),
|
||||
raising=False)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
out = capsys.readouterr().out
|
||||
assert "Gateway:" in out and "running" in out
|
||||
assert "stopped" not in out
|
||||
|
||||
|
||||
def test_short_platforms_row_lists_configured_only(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
monkeypatch.setattr(status_mod, "_PLATFORMS", {"TestNet": ("TESTNET_TOKEN", None)}, raising=False)
|
||||
monkeypatch.setenv("TESTNET_TOKEN", "token-123")
|
||||
from gateway.platform_registry import platform_registry
|
||||
monkeypatch.setattr(platform_registry, "plugin_entries", lambda: [], raising=False)
|
||||
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False, short=True))
|
||||
out = capsys.readouterr().out
|
||||
assert "Platforms: TestNet" in out
|
||||
|
||||
|
||||
def test_full_status_output_unchanged_without_short_attr(monkeypatch, capsys, tmp_path):
|
||||
status_mod, auth_mod, _ = _short_env(monkeypatch, tmp_path)
|
||||
status_mod.show_status(SimpleNamespace(all=False, deep=False))
|
||||
out = capsys.readouterr().out
|
||||
assert "☤ Hermes Agent Status" in out
|
||||
assert "◆ Environment" in out
|
||||
|
||||
@@ -742,11 +742,12 @@ has no usage endpoint, or the fetch fails (stdout stays empty).
|
||||
## `hermes status`
|
||||
|
||||
```bash
|
||||
hermes status [--all] [--deep]
|
||||
hermes status [--short] [--all] [--deep]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--short` | Compact summary: model, providers, gateway, platforms, jobs. |
|
||||
| `--all` | Show all details in a shareable redacted format. |
|
||||
| `--deep` | Run deeper checks that may take longer. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user