diff --git a/hermes_cli/kanban.py b/hermes_cli/kanban.py index 7c63d973c20..00a61b41d4d 100644 --- a/hermes_cli/kanban.py +++ b/hermes_cli/kanban.py @@ -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 ` Task details + comments + events + `stats` Per-status / per-assignee counts + `create …` 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: diff --git a/tests/hermes_cli/test_kanban_cli.py b/tests/hermes_cli/test_kanban_cli.py index 7eed9e0be2b..3d88b6212cb 100644 --- a/tests/hermes_cli/test_kanban_cli.py +++ b/tests/hermes_cli/test_kanban_cli.py @@ -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