Skip to content
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
15 changes: 14 additions & 1 deletion hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13623,7 +13623,20 @@ def cmd_sessions(args):

# Execute the command
if hasattr(args, "func"):
args.func(args)
# Propagate a subcommand's shell-style exit code. Handlers that return
# an int (e.g. cmd_kanban -> kanban_command, cmd_project) mean it as an
# exit code; discarding it made `hermes kanban comment -- t_bogus x`
# exit 0 on a handled failure, so the card-drop receiver's
# unknown-card -> 404 mapping (keyed on returncode != 0) never fired.
# Handlers returning None keep the implicit exit-0 behavior.
rc = args.func(args)
# `bool` is a subclass of `int`, so a handler returning a success/failure
# flag (e.g. plugin CLI commands registered via
# PluginContext.register_cli_command) would otherwise be treated as an
# exit code -- sys.exit(True) exits 1, inverting a success signal.
# Only propagate genuine int exit codes; bools and None keep exit-0.
if isinstance(rc, int) and not isinstance(rc, bool):
sys.exit(rc)
else:
parser.print_help()

Expand Down
98 changes: 98 additions & 0 deletions tests/hermes_cli/test_kanban_cli_exit_code.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Regression: `hermes kanban <subcommand>` must propagate its shell exit code.

Root cause (main.py dispatch): `args.func(args)` discarded the handler's return
value, so `cmd_kanban` -> `kanban_command` returning 1 on a handled failure (e.g.
commenting on an unknown task) still exited the process 0. That broke the kanban
card-drop receiver, which maps unknown-card -> 404 only when `proc.returncode != 0`.

These run the REAL CLI as a subprocess against a throwaway HERMES_HOME so the actual
`main()` exit path is exercised (an in-process handler call would bypass the bug).
"""
from __future__ import annotations

import os
import subprocess
import sys
import tempfile

import pytest


def _run_kanban(home: str, *argv: str) -> subprocess.CompletedProcess:
env = dict(os.environ)
env["HERMES_HOME"] = home
return subprocess.run(
[sys.executable, "-m", "hermes_cli.main", "kanban", *argv],
env=env,
capture_output=True,
text=True,
)


@pytest.fixture()
def isolated_home():
home = tempfile.mkdtemp(prefix="kanban_exit_code_")
os.makedirs(os.path.join(home, "profiles", "default"), exist_ok=True)
yield home


def test_comment_unknown_task_exits_nonzero(isolated_home):
"""The repro from the bug report: an unknown task id must exit non-zero."""
proc = _run_kanban(
isolated_home, "comment", "--author", "card-drop", "--", "t_nonexistent", "x"
)
assert proc.returncode != 0, (
f"expected non-zero exit for unknown task, got {proc.returncode}\n"
f"stdout={proc.stdout!r} stderr={proc.stderr!r}"
)
assert "unknown task" in proc.stderr.lower()


def test_list_success_exits_zero(isolated_home):
"""A successful subcommand still exits 0 (guards the isinstance fix)."""
proc = _run_kanban(isolated_home, "list")
assert proc.returncode == 0, (
f"expected 0 for successful list, got {proc.returncode}\n"
f"stderr={proc.stderr!r}"
)


def test_unknown_board_exits_nonzero(isolated_home):
"""`--board <typo>` on a non-existent board is a handled failure -> non-zero."""
proc = _run_kanban(isolated_home, "--board", "does-not-exist", "list")
assert proc.returncode != 0, (
f"expected non-zero exit for unknown board, got {proc.returncode}\n"
f"stderr={proc.stderr!r}"
)


@pytest.mark.parametrize("retval", ["True", "False"])
def test_bool_return_does_not_propagate_as_exit_code(isolated_home, retval):
"""A handler returning a bool (success/failure flag) must NOT be treated as
an exit code. Since bool subclasses int, sys.exit(True) would exit 1 and
invert a success signal; the dispatch guard excludes bools so bool returns
fall through to the implicit exit-0 path.

Exercises the REAL main() dispatch: an inline script points an existing
subcommand's handler at one returning a bool, then runs main() and reports
the process exit code.
"""
script = (
"import sys\n"
"from hermes_cli import main as m\n"
"_orig = m.cmd_kanban\n"
f"m.cmd_kanban = lambda args: {retval}\n"
"sys.argv = ['hermes', 'kanban', 'list']\n"
"m.main()\n"
)
env = dict(os.environ)
env["HERMES_HOME"] = isolated_home
proc = subprocess.run(
[sys.executable, "-c", script], env=env, capture_output=True, text=True
)
assert proc.returncode == 0, (
f"bool return {retval} must not propagate as a failing exit code, "
f"got {proc.returncode}\nstdout={proc.stdout!r} stderr={proc.stderr!r}"
)


Loading