diff --git a/hermes_cli/debug.py b/hermes_cli/debug.py new file mode 100644 index 0000000000..3607db9231 --- /dev/null +++ b/hermes_cli/debug.py @@ -0,0 +1,336 @@ +"""``hermes debug`` — debug tools for Hermes Agent. + +Currently supports: + hermes debug share Upload debug report (system info + logs) to a + paste service and print a shareable URL. +""" + +import io +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Optional + +from hermes_constants import get_hermes_home + + +# --------------------------------------------------------------------------- +# Paste services — try paste.rs first, dpaste.com as fallback. +# --------------------------------------------------------------------------- + +_PASTE_RS_URL = "https://paste.rs/" +_DPASTE_COM_URL = "https://dpaste.com/api/" + +# Maximum bytes to read from a single log file for upload. +# paste.rs caps at ~1 MB; we stay under that with headroom. +_MAX_LOG_BYTES = 512_000 + + +def _upload_paste_rs(content: str) -> str: + """Upload to paste.rs. Returns the paste URL. + + paste.rs accepts a plain POST body and returns the URL directly. + """ + data = content.encode("utf-8") + req = urllib.request.Request( + _PASTE_RS_URL, data=data, method="POST", + headers={ + "Content-Type": "text/plain; charset=utf-8", + "User-Agent": "hermes-agent/debug-share", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + url = resp.read().decode("utf-8").strip() + if not url.startswith("http"): + raise ValueError(f"Unexpected response from paste.rs: {url[:200]}") + return url + + +def _upload_dpaste_com(content: str, expiry_days: int = 7) -> str: + """Upload to dpaste.com. Returns the paste URL. + + dpaste.com uses multipart form data. + """ + boundary = "----HermesDebugBoundary9f3c" + + def _field(name: str, value: str) -> str: + return ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="{name}"\r\n' + f"\r\n" + f"{value}\r\n" + ) + + body = ( + _field("content", content) + + _field("syntax", "text") + + _field("expiry_days", str(expiry_days)) + + f"--{boundary}--\r\n" + ).encode("utf-8") + + req = urllib.request.Request( + _DPASTE_COM_URL, data=body, method="POST", + headers={ + "Content-Type": f"multipart/form-data; boundary={boundary}", + "User-Agent": "hermes-agent/debug-share", + }, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + url = resp.read().decode("utf-8").strip() + if not url.startswith("http"): + raise ValueError(f"Unexpected response from dpaste.com: {url[:200]}") + return url + + +def upload_to_pastebin(content: str, expiry_days: int = 7) -> str: + """Upload *content* to a paste service, trying paste.rs then dpaste.com. + + Returns the paste URL on success, raises on total failure. + """ + errors: list[str] = [] + + # Try paste.rs first (simple, fast) + try: + return _upload_paste_rs(content) + except Exception as exc: + errors.append(f"paste.rs: {exc}") + + # Fallback: dpaste.com (supports expiry) + try: + return _upload_dpaste_com(content, expiry_days=expiry_days) + except Exception as exc: + errors.append(f"dpaste.com: {exc}") + + raise RuntimeError( + "Failed to upload to any paste service:\n " + "\n ".join(errors) + ) + + +# --------------------------------------------------------------------------- +# Log file reading +# --------------------------------------------------------------------------- + +def _resolve_log_path(log_name: str) -> Optional[Path]: + """Find the log file for *log_name*, falling back to the .1 rotation. + + Returns the path if found, or None. + """ + from hermes_cli.logs import LOG_FILES + + filename = LOG_FILES.get(log_name) + if not filename: + return None + + log_dir = get_hermes_home() / "logs" + primary = log_dir / filename + if primary.exists() and primary.stat().st_size > 0: + return primary + + # Fall back to the most recent rotated file (.1). + rotated = log_dir / f"{filename}.1" + if rotated.exists() and rotated.stat().st_size > 0: + return rotated + + return None + + +def _read_log_tail(log_name: str, num_lines: int) -> str: + """Read the last *num_lines* from a log file, or return a placeholder.""" + from hermes_cli.logs import _read_last_n_lines + + log_path = _resolve_log_path(log_name) + if log_path is None: + return "(file not found)" + + try: + lines = _read_last_n_lines(log_path, num_lines) + return "".join(lines).rstrip("\n") + except Exception as exc: + return f"(error reading: {exc})" + + +def _read_full_log(log_name: str, max_bytes: int = _MAX_LOG_BYTES) -> Optional[str]: + """Read a log file for standalone upload. + + Returns the file content (last *max_bytes* if truncated), or None if the + file doesn't exist or is empty. + """ + log_path = _resolve_log_path(log_name) + if log_path is None: + return None + + try: + size = log_path.stat().st_size + if size == 0: + return None + + if size <= max_bytes: + return log_path.read_text(encoding="utf-8", errors="replace") + + # File is larger than max_bytes — read the tail. + with open(log_path, "rb") as f: + f.seek(size - max_bytes) + # Skip partial line at the seek point. + f.readline() + content = f.read().decode("utf-8", errors="replace") + return f"[... truncated — showing last ~{max_bytes // 1024}KB ...]\n{content}" + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Debug report collection +# --------------------------------------------------------------------------- + +def _capture_dump() -> str: + """Run ``hermes dump`` and return its stdout as a string.""" + from hermes_cli.dump import run_dump + + class _FakeArgs: + show_keys = False + + old_stdout = sys.stdout + sys.stdout = capture = io.StringIO() + try: + run_dump(_FakeArgs()) + except SystemExit: + pass + finally: + sys.stdout = old_stdout + + return capture.getvalue() + + +def collect_debug_report(*, log_lines: int = 200, dump_text: str = "") -> str: + """Build the summary debug report: system dump + log tails. + + Parameters + ---------- + log_lines + Number of recent lines to include per log file. + dump_text + Pre-captured dump output. If empty, ``hermes dump`` is run + internally. + + Returns the report as a plain-text string ready for upload. + """ + buf = io.StringIO() + + if not dump_text: + dump_text = _capture_dump() + buf.write(dump_text) + + # ── Recent log tails (summary only) ────────────────────────────────── + buf.write("\n\n") + buf.write(f"--- agent.log (last {log_lines} lines) ---\n") + buf.write(_read_log_tail("agent", log_lines)) + buf.write("\n\n") + + errors_lines = min(log_lines, 100) + buf.write(f"--- errors.log (last {errors_lines} lines) ---\n") + buf.write(_read_log_tail("errors", errors_lines)) + buf.write("\n\n") + + buf.write(f"--- gateway.log (last {errors_lines} lines) ---\n") + buf.write(_read_log_tail("gateway", errors_lines)) + buf.write("\n") + + return buf.getvalue() + + +# --------------------------------------------------------------------------- +# CLI entry points +# --------------------------------------------------------------------------- + +def run_debug_share(args): + """Collect debug report + full logs, upload each, print URLs.""" + log_lines = getattr(args, "lines", 200) + expiry = getattr(args, "expire", 7) + local_only = getattr(args, "local", False) + + print("Collecting debug report...") + + # Capture dump once — prepended to every paste for context. + dump_text = _capture_dump() + + report = collect_debug_report(log_lines=log_lines, dump_text=dump_text) + agent_log = _read_full_log("agent") + gateway_log = _read_full_log("gateway") + + # Prepend dump header to each full log so every paste is self-contained. + if agent_log: + agent_log = dump_text + "\n\n--- full agent.log ---\n" + agent_log + if gateway_log: + gateway_log = dump_text + "\n\n--- full gateway.log ---\n" + gateway_log + + if local_only: + print(report) + if agent_log: + print(f"\n\n{'=' * 60}") + print("FULL agent.log") + print(f"{'=' * 60}\n") + print(agent_log) + if gateway_log: + print(f"\n\n{'=' * 60}") + print("FULL gateway.log") + print(f"{'=' * 60}\n") + print(gateway_log) + return + + print("Uploading...") + urls: dict[str, str] = {} + failures: list[str] = [] + + # 1. Summary report (required) + try: + urls["Report"] = upload_to_pastebin(report, expiry_days=expiry) + except RuntimeError as exc: + print(f"\nUpload failed: {exc}", file=sys.stderr) + print("\nFull report printed below — copy-paste it manually:\n") + print(report) + sys.exit(1) + + # 2. Full agent.log (optional) + if agent_log: + try: + urls["agent.log"] = upload_to_pastebin(agent_log, expiry_days=expiry) + except Exception as exc: + failures.append(f"agent.log: {exc}") + + # 3. Full gateway.log (optional) + if gateway_log: + try: + urls["gateway.log"] = upload_to_pastebin(gateway_log, expiry_days=expiry) + except Exception as exc: + failures.append(f"gateway.log: {exc}") + + # Print results + label_width = max(len(k) for k in urls) + print(f"\nDebug report uploaded:") + for label, url in urls.items(): + print(f" {label:<{label_width}} {url}") + + if failures: + print(f"\n (failed to upload: {', '.join(failures)})") + + print(f"\nShare these links with the Hermes team for support.") + + +def run_debug(args): + """Route debug subcommands.""" + subcmd = getattr(args, "debug_command", None) + if subcmd == "share": + run_debug_share(args) + else: + # Default: show help + print("Usage: hermes debug share [--lines N] [--expire N] [--local]") + print() + print("Commands:") + print(" share Upload debug report to a paste service and print URL") + print() + print("Options:") + print(" --lines N Number of log lines to include (default: 200)") + print(" --expire N Paste expiry in days (default: 7)") + print(" --local Print report locally instead of uploading") diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 1e04008844..aacd8efad1 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -2834,6 +2834,12 @@ def cmd_dump(args): run_dump(args) +def cmd_debug(args): + """Debug tools (share report, etc.).""" + from hermes_cli.debug import run_debug + run_debug(args) + + def cmd_config(args): """Configuration management.""" from hermes_cli.config import config_command @@ -4436,6 +4442,7 @@ Examples: hermes logs -f Follow agent.log in real time hermes logs errors View errors.log hermes logs --since 1h Lines from the last hour + hermes debug share Upload debug report for support hermes update Update to latest version For more help on a command: @@ -4965,6 +4972,43 @@ For more help on a command: ) dump_parser.set_defaults(func=cmd_dump) + # ========================================================================= + # debug command + # ========================================================================= + debug_parser = subparsers.add_parser( + "debug", + help="Debug tools — upload logs and system info for support", + description="Debug utilities for Hermes Agent. Use 'hermes debug share' to " + "upload a debug report (system info + recent logs) to a paste " + "service and get a shareable URL.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""\ +Examples: + hermes debug share Upload debug report and print URL + hermes debug share --lines 500 Include more log lines + hermes debug share --expire 30 Keep paste for 30 days + hermes debug share --local Print report locally (no upload) +""", + ) + debug_sub = debug_parser.add_subparsers(dest="debug_command") + share_parser = debug_sub.add_parser( + "share", + help="Upload debug report to a paste service and print a shareable URL", + ) + share_parser.add_argument( + "--lines", type=int, default=200, + help="Number of log lines to include per log file (default: 200)", + ) + share_parser.add_argument( + "--expire", type=int, default=7, + help="Paste expiry in days (default: 7)", + ) + share_parser.add_argument( + "--local", action="store_true", + help="Print the report locally instead of uploading", + ) + debug_parser.set_defaults(func=cmd_debug) + # ========================================================================= # backup command # ========================================================================= diff --git a/tests/hermes_cli/test_debug.py b/tests/hermes_cli/test_debug.py new file mode 100644 index 0000000000..f733c8ab64 --- /dev/null +++ b/tests/hermes_cli/test_debug.py @@ -0,0 +1,461 @@ +"""Tests for ``hermes debug`` CLI command and debug utilities.""" + +import os +import sys +import urllib.error +from pathlib import Path +from unittest.mock import MagicMock, patch, call + +import pytest + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def hermes_home(tmp_path, monkeypatch): + """Set up an isolated HERMES_HOME with minimal logs.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + # Create log files + logs_dir = home / "logs" + logs_dir.mkdir() + (logs_dir / "agent.log").write_text( + "2026-04-12 17:00:00 INFO agent: session started\n" + "2026-04-12 17:00:01 INFO tools.terminal: running ls\n" + "2026-04-12 17:00:02 WARNING agent: high token usage\n" + ) + (logs_dir / "errors.log").write_text( + "2026-04-12 17:00:05 ERROR gateway.run: connection lost\n" + ) + (logs_dir / "gateway.log").write_text( + "2026-04-12 17:00:10 INFO gateway.run: started\n" + ) + + return home + + +# --------------------------------------------------------------------------- +# Unit tests for upload helpers +# --------------------------------------------------------------------------- + +class TestUploadPasteRs: + """Test paste.rs upload path.""" + + def test_upload_paste_rs_success(self): + from hermes_cli.debug import _upload_paste_rs + + mock_resp = MagicMock() + mock_resp.read.return_value = b"https://paste.rs/abc123\n" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + url = _upload_paste_rs("hello world") + + assert url == "https://paste.rs/abc123" + + def test_upload_paste_rs_bad_response(self): + from hermes_cli.debug import _upload_paste_rs + + mock_resp = MagicMock() + mock_resp.read.return_value = b"error" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + with pytest.raises(ValueError, match="Unexpected response"): + _upload_paste_rs("test") + + def test_upload_paste_rs_network_error(self): + from hermes_cli.debug import _upload_paste_rs + + with patch( + "hermes_cli.debug.urllib.request.urlopen", + side_effect=urllib.error.URLError("connection refused"), + ): + with pytest.raises(urllib.error.URLError): + _upload_paste_rs("test") + + +class TestUploadDpasteCom: + """Test dpaste.com fallback upload path.""" + + def test_upload_dpaste_com_success(self): + from hermes_cli.debug import _upload_dpaste_com + + mock_resp = MagicMock() + mock_resp.read.return_value = b"https://dpaste.com/ABCDEFG\n" + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("hermes_cli.debug.urllib.request.urlopen", return_value=mock_resp): + url = _upload_dpaste_com("hello world", expiry_days=7) + + assert url == "https://dpaste.com/ABCDEFG" + + +class TestUploadToPastebin: + """Test the combined upload with fallback.""" + + def test_tries_paste_rs_first(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + return_value="https://paste.rs/test") as prs: + url = upload_to_pastebin("content") + + assert url == "https://paste.rs/test" + prs.assert_called_once() + + def test_falls_back_to_dpaste_com(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + side_effect=Exception("down")), \ + patch("hermes_cli.debug._upload_dpaste_com", + return_value="https://dpaste.com/TEST") as dp: + url = upload_to_pastebin("content") + + assert url == "https://dpaste.com/TEST" + dp.assert_called_once() + + def test_raises_when_both_fail(self): + from hermes_cli.debug import upload_to_pastebin + + with patch("hermes_cli.debug._upload_paste_rs", + side_effect=Exception("err1")), \ + patch("hermes_cli.debug._upload_dpaste_com", + side_effect=Exception("err2")): + with pytest.raises(RuntimeError, match="Failed to upload"): + upload_to_pastebin("content") + + +# --------------------------------------------------------------------------- +# Log reading +# --------------------------------------------------------------------------- + +class TestReadFullLog: + """Test _read_full_log for standalone log uploads.""" + + def test_reads_small_file(self, hermes_home): + from hermes_cli.debug import _read_full_log + + content = _read_full_log("agent") + assert content is not None + assert "session started" in content + + def test_returns_none_for_missing(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import _read_full_log + assert _read_full_log("agent") is None + + def test_returns_none_for_empty(self, hermes_home): + # Truncate agent.log to empty + (hermes_home / "logs" / "agent.log").write_text("") + + from hermes_cli.debug import _read_full_log + assert _read_full_log("agent") is None + + def test_truncates_large_file(self, hermes_home): + """Files larger than max_bytes get tail-truncated.""" + from hermes_cli.debug import _read_full_log + + # Write a file larger than 1KB + big_content = "x" * 100 + "\n" + (hermes_home / "logs" / "agent.log").write_text(big_content * 200) + + content = _read_full_log("agent", max_bytes=1024) + assert content is not None + assert "truncated" in content + + def test_unknown_log_returns_none(self, hermes_home): + from hermes_cli.debug import _read_full_log + assert _read_full_log("nonexistent") is None + + def test_falls_back_to_rotated_file(self, hermes_home): + """When gateway.log doesn't exist, falls back to gateway.log.1.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + # Remove the primary (if any) and create a .1 rotation + (logs_dir / "gateway.log").unlink(missing_ok=True) + (logs_dir / "gateway.log.1").write_text( + "2026-04-12 10:00:00 INFO gateway.run: rotated content\n" + ) + + content = _read_full_log("gateway") + assert content is not None + assert "rotated content" in content + + def test_prefers_primary_over_rotated(self, hermes_home): + """Primary log is used when it exists, even if .1 also exists.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + (logs_dir / "gateway.log").write_text("primary content\n") + (logs_dir / "gateway.log.1").write_text("rotated content\n") + + content = _read_full_log("gateway") + assert "primary content" in content + assert "rotated" not in content + + def test_falls_back_when_primary_empty(self, hermes_home): + """Empty primary log falls back to .1 rotation.""" + from hermes_cli.debug import _read_full_log + + logs_dir = hermes_home / "logs" + (logs_dir / "agent.log").write_text("") + (logs_dir / "agent.log.1").write_text("rotated agent data\n") + + content = _read_full_log("agent") + assert content is not None + assert "rotated agent data" in content + + +# --------------------------------------------------------------------------- +# Debug report collection +# --------------------------------------------------------------------------- + +class TestCollectDebugReport: + """Test the debug report builder.""" + + def test_report_includes_dump_output(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump") as mock_dump: + mock_dump.side_effect = lambda args: print( + "--- hermes dump ---\nversion: 0.8.0\n--- end dump ---" + ) + report = collect_debug_report(log_lines=50) + + assert "--- hermes dump ---" in report + assert "version: 0.8.0" in report + + def test_report_includes_agent_log(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "--- agent.log" in report + assert "session started" in report + + def test_report_includes_errors_log(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "--- errors.log" in report + assert "connection lost" in report + + def test_report_includes_gateway_log(self, hermes_home): + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "--- gateway.log" in report + + def test_missing_logs_handled(self, tmp_path, monkeypatch): + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import collect_debug_report + + with patch("hermes_cli.dump.run_dump"): + report = collect_debug_report(log_lines=50) + + assert "(file not found)" in report + + +# --------------------------------------------------------------------------- +# CLI entry point — run_debug_share +# --------------------------------------------------------------------------- + +class TestRunDebugShare: + """Test the run_debug_share CLI handler.""" + + def test_local_flag_prints_full_logs(self, hermes_home, capsys): + """--local prints the report plus full log contents.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug_share(args) + + out = capsys.readouterr().out + assert "--- agent.log" in out + assert "FULL agent.log" in out + assert "FULL gateway.log" in out + + def test_share_uploads_three_pastes(self, hermes_home, capsys): + """Successful share uploads report + agent.log + gateway.log.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + uploaded_content = [] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + uploaded_content.append(content) + return f"https://paste.rs/paste{call_count[0]}" + + with patch("hermes_cli.dump.run_dump") as mock_dump, \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + mock_dump.side_effect = lambda a: print("--- hermes dump ---\nversion: test\n--- end dump ---") + run_debug_share(args) + + out = capsys.readouterr().out + # Should have 3 uploads: report, agent.log, gateway.log + assert call_count[0] == 3 + assert "paste.rs/paste1" in out # Report + assert "paste.rs/paste2" in out # agent.log + assert "paste.rs/paste3" in out # gateway.log + assert "Report" in out + assert "agent.log" in out + assert "gateway.log" in out + + # Each log paste should start with the dump header + agent_paste = uploaded_content[1] + assert "--- hermes dump ---" in agent_paste + assert "--- full agent.log ---" in agent_paste + gateway_paste = uploaded_content[2] + assert "--- hermes dump ---" in gateway_paste + assert "--- full gateway.log ---" in gateway_paste + + def test_share_skips_missing_logs(self, tmp_path, monkeypatch, capsys): + """Only uploads logs that exist.""" + home = tmp_path / ".hermes" + home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(home)) + + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + return f"https://paste.rs/paste{call_count[0]}" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + run_debug_share(args) + + out = capsys.readouterr().out + # Only the report should be uploaded (no log files exist) + assert call_count[0] == 1 + assert "Report" in out + + def test_share_continues_on_log_upload_failure(self, hermes_home, capsys): + """Log upload failure doesn't stop the report from being shared.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + call_count = [0] + def _mock_upload(content, expiry_days=7): + call_count[0] += 1 + if call_count[0] > 1: + raise RuntimeError("upload failed") + return "https://paste.rs/report" + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=_mock_upload): + run_debug_share(args) + + out = capsys.readouterr().out + assert "Report" in out + assert "paste.rs/report" in out + assert "failed to upload" in out + + def test_share_exits_on_report_upload_failure(self, hermes_home, capsys): + """If the main report fails to upload, exit with code 1.""" + from hermes_cli.debug import run_debug_share + + args = MagicMock() + args.lines = 50 + args.expire = 7 + args.local = False + + with patch("hermes_cli.dump.run_dump"), \ + patch("hermes_cli.debug.upload_to_pastebin", + side_effect=RuntimeError("all failed")): + with pytest.raises(SystemExit) as exc_info: + run_debug_share(args) + + assert exc_info.value.code == 1 + out = capsys.readouterr() + assert "all failed" in out.err + + +# --------------------------------------------------------------------------- +# run_debug router +# --------------------------------------------------------------------------- + +class TestRunDebug: + def test_no_subcommand_shows_usage(self, capsys): + from hermes_cli.debug import run_debug + + args = MagicMock() + args.debug_command = None + + run_debug(args) + + out = capsys.readouterr().out + assert "hermes debug share" in out + + def test_share_subcommand_routes(self, hermes_home): + from hermes_cli.debug import run_debug + + args = MagicMock() + args.debug_command = "share" + args.lines = 200 + args.expire = 7 + args.local = True + + with patch("hermes_cli.dump.run_dump"): + run_debug(args) + + +# --------------------------------------------------------------------------- +# Argparse integration +# --------------------------------------------------------------------------- + +class TestArgparseIntegration: + def test_module_imports_clean(self): + from hermes_cli.debug import run_debug, run_debug_share + assert callable(run_debug) + assert callable(run_debug_share) + + def test_cmd_debug_dispatches(self): + from hermes_cli.main import cmd_debug + + args = MagicMock() + args.debug_command = None + cmd_debug(args)