fix(kanban): /kanban slash command emits argparse garbage instead of help

Closes #21794.

`/kanban`, `/kanban help`, `/kanban --help`, and `/kanban <sub> -h`
all returned broken output to the gateway and interactive CLI. Three
underlying bugs in `hermes_cli.kanban.run_slash`:

1. argparse writes help to **stdout** but `run_slash` only captured
   stderr at parse time, so `-h` text was silently swallowed and
   replaced with the `(usage error: 0)` sentinel.
2. The wrapping parser used `prog="/"` and routed via a synthetic
   "_top → kanban" subparser, producing `usage: / kanban …` (stray
   space) and `usage: /kanban kanban …` (doubled token) in error text.
3. Bare `/kanban` and `/kanban help` dumped argparse's full ~3KB
   usage tree, which reads as visual garbage in a chat bubble.

Fix: drive the kanban_parser directly (no double-wrap), rewrite prog
strings on every leaf subparser, capture stdout AND stderr around
parse_args, distinguish SystemExit(0) (help — return captured stdout)
from SystemExit(2) (error — return single-line ⚠-prefixed message),
and add an explicit chat-friendly short-help block returned for bare
invocation and the help aliases (`help`, `--help`, `-h`, `?`).

Added 5 regression tests covering bare invocation, every help alias,
subcommand help, unknown action, and missing required arg.

Affects every chat platform via gateway/run.py::_handle_kanban_command
and the interactive CLI via cli.py::_handle_kanban_command.

Co-Authored-By: Nagatha (Claude Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
tymrtn
2026-05-08 11:58:28 +02:00
committed by Teknium
parent 3d2bfc502e
commit d1fc748def
2 changed files with 118 additions and 23 deletions

View File

@@ -2136,6 +2136,29 @@ def _cmd_gc(args: argparse.Namespace) -> int:
# Slash-command entry point (used by /kanban from CLI and gateway)
# ---------------------------------------------------------------------------
_SLASH_KANBAN_HELP = """\
**/kanban** — manage the shared task board.
Common subcommands:
`list` (alias `ls`) List tasks on the current board
`show <id>` Task details + comments + events
`stats` Per-status / per-assignee counts
`create <title>…` Create a task (auto-subscribes you to events)
`comment <id> <msg>` Append a comment
`complete <id>…` Mark task(s) done
`block <id> [reason]` Mark blocked; `unblock <id>` to revive
`assign <id> <profile>` Reassign
`boards list` Show all boards
`assignees` Known profiles + counts
`context <id>` Full worker-context dump
`runs <id>` Attempt history
`log <id>` Worker log
Run `/kanban <subcommand> -h` for arguments. \
Read-only commands are safe while an agent is running.\
"""
def run_slash(rest: str) -> str:
"""Execute a ``/kanban …`` string and return captured stdout/stderr.
@@ -2148,26 +2171,47 @@ def run_slash(rest: str) -> str:
tokens = shlex.split(rest) if rest and rest.strip() else []
parser = argparse.ArgumentParser(prog="/kanban", add_help=False)
parser.exit_on_error = False # type: ignore[attr-defined]
sub = parser.add_subparsers(dest="kanban_action")
# Reuse the argparse builder -- call it with a throwaway parent
# subparsers via a wrapping top-level parser.
wrap = argparse.ArgumentParser(prog="/", add_help=False)
wrap.exit_on_error = False # type: ignore[attr-defined]
wrap_sub = wrap.add_subparsers(dest="_top")
build_parser(wrap_sub)
# Bare ``/kanban`` or ``/kanban help`` / ``--help`` / ``-h`` / ``?``:
# show the curated short-help block instead of dumping argparse's full
# usage tree (which is enormous and reads as garbage in a chat
# bubble). Per-subcommand help still works via ``/kanban foo -h``.
if not tokens or tokens[0] in {"help", "--help", "-h", "?"}:
return _SLASH_KANBAN_HELP
# Single argparse tree rooted at "/kanban". build_parser() expects a
# subparsers action to attach to, so build a throwaway one and pull
# the kanban_parser back out — then drive it directly so usage/error
# text reads as ``/kanban`` (not ``/kanban-wrap kanban``).
_wrap = argparse.ArgumentParser(prog="/kanban-wrap", add_help=False)
_wrap.exit_on_error = False # type: ignore[attr-defined]
_top_sub = _wrap.add_subparsers(dest="_top")
kanban_parser = build_parser(_top_sub)
kanban_parser.prog = "/kanban"
kanban_parser.exit_on_error = False # type: ignore[attr-defined]
for _action in kanban_parser._actions:
if isinstance(_action, argparse._SubParsersAction):
for _name, _choice in _action.choices.items():
_choice.prog = f"/kanban {_name}"
_choice.exit_on_error = False # type: ignore[attr-defined]
buf_out = io.StringIO()
buf_err = io.StringIO()
# ``-h`` / ``--help`` makes argparse print to stdout and SystemExit(0).
# Capture both streams so neither the help text nor the error text
# bypasses our buffer.
try:
# Prepend the "kanban" token so our top-level subparser routes here.
argv = ["kanban", *tokens] if tokens else ["kanban"]
args = wrap.parse_args(argv)
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
args = kanban_parser.parse_args(tokens)
except SystemExit as exc:
return f"(usage error: {exc})"
out = buf_out.getvalue().rstrip()
err = buf_err.getvalue().rstrip()
# Help dump (exit 0) → return the captured help text directly.
if exc.code in (0, None) and out:
return out
body = err or out
return f"⚠ /kanban usage error\n{body}" if body else "⚠ /kanban usage error"
except argparse.ArgumentError as exc:
return f"(usage error: {exc})"
return f"⚠ /kanban usage error: {exc}"
with contextlib.redirect_stdout(buf_out), contextlib.redirect_stderr(buf_err):
try:

View File

@@ -331,13 +331,64 @@ def test_run_slash_specify_end_to_end(kanban_home, monkeypatch):
def test_run_slash_specify_help_is_reachable(kanban_home):
"""`--help` on a subcommand is handled by argparse itself — it prints
to the process stdout and raises SystemExit before run_slash's output
redirection is installed, so the returned string is the usage-error
sentinel. All we're asserting here is that the subcommand is
registered (no "unknown action" error) — the shape of the help text
is covered by the direct argparse tests in test_kanban_specify.py."""
"""`-h`/`--help` on a subcommand returns the actual help text — see
issue #21794. argparse writes help to stdout and exits 0; run_slash
must capture both streams and treat exit 0 as success, not error."""
out = kc.run_slash("specify --help")
# Either the usage-error sentinel (stdout swallowed by argparse) or
# a real help rendering — both mean the subcommand exists.
assert "usage error" in out.lower() or "specify" in out.lower()
assert "specify" in out.lower()
# Help dump should NOT come back wrapped as a usage error.
assert not out.startswith("")
# ---------------------------------------------------------------------------
# /kanban help / no-args / unknown-action UX (issue #21794)
# ---------------------------------------------------------------------------
def test_run_slash_bare_returns_curated_help(kanban_home):
"""Bare `/kanban` returns the curated short-help block — not a 5KB
argparse usage dump."""
out = kc.run_slash("")
assert "/kanban" in out
assert "list" in out
assert "show" in out
# Sanity: should be a chat-friendly size, not the raw usage tree.
assert len(out) < 2000
# Shouldn't surface argparse's usage-error sentinel.
assert "usage error" not in out.lower()
@pytest.mark.parametrize("alias", ["help", "--help", "-h", "?"])
def test_run_slash_help_aliases_match_bare(kanban_home, alias):
"""Every documented help alias produces the same curated output."""
bare = kc.run_slash("")
out = kc.run_slash(alias)
assert out == bare
def test_run_slash_subcommand_help_returns_help_text(kanban_home):
"""`/kanban show -h` returns the actual subcommand help, not a
fake `(usage error: 0)` sentinel."""
out = kc.run_slash("show -h")
assert "task_id" in out
assert "/kanban show" in out
assert not out.startswith("")
def test_run_slash_unknown_action_friendly_error(kanban_home):
"""Unknown subcommand surfaces a single-line usage error prefixed
with our marker — no `(usage error: 2)` wrapping, no doubled
`kanban kanban` prog string."""
out = kc.run_slash("frobnicate")
assert "/kanban" in out
assert "frobnicate" in out
assert "/kanban-wrap" not in out
assert "/kanban kanban" not in out
assert "(usage error: " not in out
def test_run_slash_missing_required_arg_friendly_error(kanban_home):
"""Missing positional argument shows the subcommand-scoped usage
line, not the top-level kanban tree."""
out = kc.run_slash("show")
assert "/kanban show" in out
assert "task_id" in out