diff --git a/hermes_cli/status.py b/hermes_cli/status.py index aa50196a88..b3b54565f8 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -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) diff --git a/hermes_cli/status_auth.py b/hermes_cli/status_auth.py index 204af45878..f4ca4969b7 100644 --- a/hermes_cli/status_auth.py +++ b/hermes_cli/status_auth.py @@ -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) diff --git a/hermes_cli/subcommands/status.py b/hermes_cli/subcommands/status.py index 95e2d1e631..d08402917a 100644 --- a/hermes_cli/subcommands/status.py +++ b/hermes_cli/subcommands/status.py @@ -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( diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 8d96d64548..66077055f3 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -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 diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index e59b7e73c3..eeb0ff888a 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -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. |