feat(debug): support /debug [nous|local] in the CLI/TUI slash command

The --nous flag was only wired into the argparse `hermes debug share`
subcommand. The /debug slash command (classic CLI + TUI, both via
process_command -> _handle_debug_command) built a hardcoded args
namespace with no `nous` attribute, so it always took the default
paste.rs path.

Pass cmd_original through to _handle_debug_command and parse an optional
destination word:

  /debug         -> public paste (default, unchanged)
  /debug nous    -> Nous-internal S3
  /debug local   -> stdout, no upload

local wins over nous (never touches the network); unknown words fall
back to the default. Add args_hint="[nous|local]" so help/autocomplete
surface it. New TestDebugSlashCommand covers the parsing + dispatch.
This commit is contained in:
Ben 2026-06-09 16:13:13 +10:00 committed by Teknium
parent 89653db403
commit 98d550e035
4 changed files with 78 additions and 5 deletions

2
cli.py
View file

@ -8385,7 +8385,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
elif canonical == "copy":
self._handle_copy_command(cmd_original)
elif canonical == "debug":
self._handle_debug_command()
self._handle_debug_command(cmd_original)
elif canonical == "update":
if self._handle_update_command():
return False

View file

@ -2575,12 +2575,25 @@ class CLICommandsMixin:
else:
_cprint(f" {_ACCENT}{feature_name} set to {label} (session only){_RST}")
def _handle_debug_command(self):
"""Handle /debug — upload debug report + logs and print paste URLs."""
def _handle_debug_command(self, cmd_original: str = ""):
"""Handle /debug — upload debug report + logs and print share URLs.
Accepts optional destination words after the command:
- ``/debug`` upload to the public paste service (default)
- ``/debug nous`` upload to Nous-internal storage (private, staff-only)
- ``/debug local`` render the report to stdout, no upload
``nous`` and ``local`` are mutually exclusive; if both are given,
``local`` wins (it never touches the network).
"""
from hermes_cli.debug import run_debug_share
from types import SimpleNamespace
args = SimpleNamespace(lines=200, expire=7, local=False)
words = {w.lower() for w in cmd_original.split()[1:]}
local = "local" in words
nous = "nous" in words and not local
args = SimpleNamespace(lines=200, expire=7, local=local, nous=nous)
run_debug_share(args)
def _handle_update_command(self) -> bool:

View file

@ -246,7 +246,8 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True, args_hint="<path>"),
CommandDef("update", "Update Hermes Agent to the latest version", "Info"),
CommandDef("version", "Show Hermes Agent version", "Info", aliases=("v",)),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info"),
CommandDef("debug", "Upload debug report (system info + logs) and get shareable links", "Info",
args_hint="[nous|local]"),
# Exit
CommandDef("quit", "Exit the CLI (use --delete to also remove session history)", "Exit",

View file

@ -1570,3 +1570,62 @@ class TestRunDebugShareNous:
run_debug_share(self._args())
paste.assert_not_called()
class TestDebugSlashCommand:
"""`/debug [nous|local]` parsing in the CLI/TUI handler.
The classic CLI and the TUI slash worker both dispatch through
``HermesCLI.process_command`` ``_handle_debug_command(cmd_original)``,
which parses an optional destination word and builds the args namespace
handed to ``run_debug_share``.
"""
def _handler(self):
from hermes_cli.cli_commands_mixin import CLICommandsMixin
class _Stub(CLICommandsMixin):
pass
return _Stub()._handle_debug_command
def _captured(self, cmd_original):
captured = {}
def _fake_run(args):
captured.update(vars(args))
with patch("hermes_cli.debug.run_debug_share", _fake_run):
self._handler()(cmd_original)
return captured
def test_bare_debug_defaults_to_paste(self):
c = self._captured("/debug")
assert c["nous"] is False and c["local"] is False
assert c["lines"] == 200 and c["expire"] == 7
def test_nous_word_sets_nous(self):
c = self._captured("/debug nous")
assert c["nous"] is True and c["local"] is False
def test_local_word_sets_local(self):
c = self._captured("/debug local")
assert c["local"] is True and c["nous"] is False
def test_word_parsing_is_case_insensitive(self):
c = self._captured("/debug NOUS")
assert c["nous"] is True
def test_local_wins_over_nous(self):
# local never touches the network, so it takes precedence.
c = self._captured("/debug nous local")
assert c["local"] is True and c["nous"] is False
def test_unknown_word_falls_back_to_default(self):
c = self._captured("/debug paste")
assert c["nous"] is False and c["local"] is False
def test_no_arg_default_keyword(self):
# Calling with no cmd_original (legacy callers) must still work.
c = self._captured("")
assert c["nous"] is False and c["local"] is False