Skip to content
This repository was archived by the owner on Sep 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13921,9 +13921,14 @@ def cmd_acp(args):
cmd_chat(args)
return

# Execute the command
# Execute the command. Handlers that return an int are signalling an exit
# code (plugin CLI commands use 1 for user errors, 2 for usage errors) β€”
# propagate it so scripted/cron callers can branch on failure instead of
# every error exiting 0.
if hasattr(args, "func"):
args.func(args)
rc = args.func(args)
if isinstance(rc, int) and rc != 0:
sys.exit(rc)
else:
parser.print_help()

Expand Down
78 changes: 78 additions & 0 deletions tests/hermes_cli/test_cli_exit_code_propagation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""hermes_cli.main's top-level dispatch propagates handler exit codes.

Before this fix, ``main()`` called ``args.func(args)`` and discarded the
return value, so every subcommand that returns a non-zero int for a real
error (1 = user error, 2 = usage error β€” the convention used by plugin CLI
commands, ``kanban``, ``migrate``, etc.) still exited the process with 0.
That silently broke scripted/cron use of any of those commands: a caller
checking ``$?`` could never see a failure.

These are subprocess-level tests (the real entry point, not a mocked
dispatch) because ``main()`` builds the entire parser inline and there is
no smaller seam to unit-test the dispatch line in isolation β€” the existing
convention in this test suite (see ``test_kanban_core_functionality.py``)
is the same real-subprocess approach.
"""

from __future__ import annotations

import os
import subprocess
import sys


def _run_hermes(argv: list[str], env: dict) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", *argv],
capture_output=True,
text=True,
env=env,
)


def _isolated_env(tmp_path) -> dict:
"""A fresh HERMES_HOME with the `crm` plugin enabled.

`crm` is ``kind: standalone`` β€” opt-in via ``plugins.enabled`` β€” so its
CLI subcommand isn't registered in the parser at all until enabled.
"""
home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text(
"plugins:\n enabled:\n - crm\n", encoding="utf-8"
)
env = os.environ.copy()
env["HERMES_HOME"] = str(home)
return env


def test_plugin_command_user_error_exits_nonzero(tmp_path):
"""A plugin CLI command's documented error exit code reaches the shell.

The `crm` plugin returns 1 for a user error (unknown contact).
"""
env = _isolated_env(tmp_path)
r = _run_hermes(
["crm", "show", "nobody", "--store-path", str(tmp_path / "crm.json")],
env,
)
assert "Unknown contact" in r.stdout
assert r.returncode == 1, r.stdout + r.stderr


def test_plugin_command_success_exits_zero(tmp_path):
env = _isolated_env(tmp_path)
store = str(tmp_path / "crm.json")
r = _run_hermes(["crm", "add", "Exit Code Test", "--store-path", store], env)
assert r.returncode == 0, r.stdout + r.stderr

r = _run_hermes(["crm", "list", "--store-path", store], env)
assert r.returncode == 0, r.stdout + r.stderr
assert "Exit Code Test" in r.stdout


def test_usage_error_exits_two(tmp_path):
"""crm_command's own usage-error convention (missing subcommand)."""
env = _isolated_env(tmp_path)
r = _run_hermes(["crm"], env)
assert r.returncode == 2, r.stdout + r.stderr
6 changes: 2 additions & 4 deletions tests/hermes_cli/test_kanban_core_functionality.py
Original file line number Diff line number Diff line change
Expand Up @@ -1955,10 +1955,7 @@ def test_cli_bulk_complete_with_summary_rejects(kanban_home):
kb.claim_task(conn, a); kb.claim_task(conn, b)
finally:
conn.close()
# Bulk + summary is refused (stderr message, no mutation).
# Note: hermes_cli.main doesn't propagate sub-command exit codes
# (args.func(args) discards the return value), so we check the side
# effects instead.
# Bulk + summary is refused (stderr message, exit code, no mutation).
from subprocess import run as _run
import os, sys
env = os.environ.copy()
Expand All @@ -1968,6 +1965,7 @@ def test_cli_bulk_complete_with_summary_rejects(kanban_home):
capture_output=True, text=True, env=env,
)
assert "per-task" in r.stderr, r.stderr
assert r.returncode == 2, r.stderr
# The tasks must still be running (no partial apply).
conn = kb.connect()
try:
Expand Down
10 changes: 10 additions & 0 deletions tests/hermes_cli/test_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@ def test_setup_gateway_skips_service_install_when_systemctl_missing(monkeypatch,
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
# _setup_standard_platform() (gateway.py) calls its own imported
# prompt_yes_no reference -- Matrix is now pre-selected (already
# configured), so _configure_platform() reaches the "Reconfigure
# Matrix?" prompt. Decline it so the already-set credentials are kept.
monkeypatch.setattr(gateway_mod, "prompt_yes_no", lambda *args, **kwargs: False)
# setup_gateway() now drives platform selection through an interactive
# checkbox picker; simulate a user who accepts the pre-selected (already
# configured) platforms rather than toggling anything.
Expand Down Expand Up @@ -226,6 +231,11 @@ def test_setup_gateway_in_container_shows_docker_guidance(monkeypatch, capsys):
monkeypatch.setattr(setup_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(gateway_mod, "get_env_value", lambda key: env.get(key, ""))
monkeypatch.setattr(setup_mod, "prompt_yes_no", lambda *args, **kwargs: False)
# _setup_standard_platform() (gateway.py) calls its own imported
# prompt_yes_no reference -- Matrix is now pre-selected (already
# configured), so _configure_platform() reaches the "Reconfigure
# Matrix?" prompt. Decline it so the already-set credentials are kept.
monkeypatch.setattr(gateway_mod, "prompt_yes_no", lambda *args, **kwargs: False)
# setup_gateway() now drives platform selection through an interactive
# checkbox picker; simulate a user who accepts the pre-selected (already
# configured) platforms rather than toggling anything.
Expand Down
Loading