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:
PhoeniXAbhisheK
2026-10-01 23:57:21 +05:30
committed by Teknium
parent fd6a6fbf2e
commit c82ad613ec
5 changed files with 332 additions and 13 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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

View File

@@ -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. |