Skip to content

fix(cli): propagate subcommand exit codes from the top-level dispatch - #96

Merged
dizhaky merged 2 commits into
mainfrom
claude/fix-cli-exit-code-propagation
Jul 17, 2026
Merged

fix(cli): propagate subcommand exit codes from the top-level dispatch#96
dizhaky merged 2 commits into
mainfrom
claude/fix-cli-exit-code-propagation

Conversation

@dizhaky

@dizhaky dizhaky commented Jul 17, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Found while auditing the crm plugin (#88): hermes crm show nobody prints "Unknown contact: nobody" and exits 0, because main()'s top-level dispatch called args.func(args) and discarded whatever it returned.

This isn't crm-specific — every command handler that follows the 0/1/2 exit-code convention (plugin CLI commands, hermes kanban, hermes migrate, hermes slack) was silently losing its exit code the same way, breaking $?-based scripting and cron error detection for all of them.

Related Issue

Fixes #

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • hermes_cli/main.py: propagate a non-None, non-zero int return from args.func(args) via sys.exit().
  • tests/hermes_cli/test_cli_exit_code_propagation.py (new): subprocess-level tests against the real python -m hermes_cli.main entry point covering the 0/1/2 convention via the crm plugin (whose exit-code contract is well-defined and easy to isolate).
  • tests/hermes_cli/test_kanban_core_functionality.py: tightened an existing test that had an explicit comment documenting the old swallow-behavior (it was checking side effects only because there was nothing else to check) — now also asserts the exit code.

How to Test

  1. pytest tests/hermes_cli/test_cli_exit_code_propagation.py -q → 3 passed.
  2. pytest tests/hermes_cli/test_kanban_core_functionality.py tests/hermes_cli/test_kanban_cli.py tests/hermes_cli/test_migrate_xai.py tests/hermes_cli/test_slack_cli.py tests/hermes_cli/test_plugin_cli_registration.py -q → 254 passed, confirming no regression across every handler category that has explicit test coverage of its return value.
  3. Manual: hermes crm show nobody; echo $? → prints the error, exits 1 (was 0).

Safety analysis

This changes the dispatch for all ~49 set_defaults(func=...) registrations in hermes_cli/main.py, not just crm, so before applying it I had a subagent trace every one of them to their actual definitions and check for any handler that returns a non-zero int for a reason other than signaling a real error (e.g. a count, a boolean-as-int, an object). None were found — every handler either implicitly returns None (most core cmd_* wrappers call-and-discard internally) or already follows the 0/1/2 convention (plugin commands, kanban, migrate, slack). cmd_proxy already converts its own non-zero return to SystemExit before returning, so it's unaffected either way.

Checklist

Code

  • Commit messages follow Conventional Commits (fix(cli): …)
  • PR contains only changes related to this fix
  • ruff check passes
  • Tests pass; 1 new test file, 1 existing test tightened
  • Tested on my platform: Ubuntu (Linux 6.18.5)

Documentation & Housekeeping

  • Cross-platform impact — N/A, no platform-specific code paths touched
  • cli-config.yaml.example — N/A (no config keys changed)

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bym5HhKPQ3CWb5r9bCvBq4


Generated by Claude Code

Found while auditing the crm plugin (#88): `hermes crm show nobody`
prints "Unknown contact: nobody" and exits 0, because main()'s top-level
dispatch called `args.func(args)` and discarded whatever it returned.

This isn't crm-specific — every command handler that follows the 0/1/2
exit-code convention (plugin CLI commands, `hermes kanban`, `hermes
migrate`, `hermes slack`) was silently losing its exit code the same way,
breaking `$?`-based scripting and cron error detection for all of them.

Propagate a non-None, non-zero int return via sys.exit(). Verified safe
against a full audit of hermes_cli/main.py's ~49 set_defaults(func=...)
registrations: no handler returns a non-zero int for a reason other than
"this is a real error" (most wrappers call-and-discard internally and
stay None regardless; the ones with explicit returns already follow the
0/1/2 convention).

Adds a subprocess-level regression test file exercising the real
`python -m hermes_cli.main` entry point (0/1/2 via the crm plugin, since
its exit-code contract is well-defined), and tightens an existing kanban
test that had an explicit comment documenting the swallow-behavior — it
was checking side effects only because there was nothing else to check;
now it also asserts the exit code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bym5HhKPQ3CWb5r9bCvBq4
@github-actions

Copy link
Copy Markdown

🔎 Lint report: claude/fix-cli-exit-code-propagation vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 8637 on HEAD, 8637 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 4605 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Pre-existing, unrelated to this PR's exit-code changes — found while
triaging CI red across the fan-out audit follow-up PRs.

_setup_standard_platform() (hermes_cli/gateway.py) calls its own
imported prompt_yes_no, not hermes_cli.setup's. Since Matrix now shows
as "already configured" in these tests' env fixture,
_configure_platform() reaches the "Reconfigure Matrix?" prompt, which
was only mocked on the setup_mod side — the real gateway_mod reference
fell through to a live input() call and crashed under pytest's
captured stdout with "reading from stdin while output is captured".

Mock gateway_mod.prompt_yes_no too, matching the pattern PR #93
independently arrived at for the same root cause.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bym5HhKPQ3CWb5r9bCvBq4
@dizhaky
dizhaky marked this pull request as ready for review July 17, 2026 16:02
@dizhaky
dizhaky merged commit b763b0f into main Jul 17, 2026
43 of 46 checks passed
@dizhaky
dizhaky deleted the claude/fix-cli-exit-code-propagation branch July 17, 2026 16:02
dizhaky added a commit that referenced this pull request Jul 17, 2026
…ssion (#98)

Adds docs/system-log/ (previously absent) with the account-wide README convention and a first entry retroactively documenting PRs #94, #95, #96 merged earlier this session.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants