From e30d0639bb0abb65d4417ebc9c99c7ae0ddba8f0 Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Sun, 13 Sep 2026 12:35:42 -0700
Subject: [PATCH] fix(cli): sessions export accepts a directory for single-file
formats
`hermes sessions export --session-id X
/` crashed with IsADirectoryError
because jsonl/html/trace opened the positional as a file while --help called it
an "output path" and md/qmd really do take a directory. An existing directory
(or one spelled with a trailing separator) now receives a default-named file
(`hermes_session_.`), and the help text spells out per-format what
OUTPUT means.
---
hermes_cli/sessions_cmd.py | 16 ++++++++++++++++
hermes_cli/subcommands/sessions.py | 7 ++++---
.../test_sessions_export_output_dir.py | 2 +-
website/docs/user-guide/sessions.md | 4 ++++
4 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py
index 37b95fd7b6..c08e91449f 100644
--- a/hermes_cli/sessions_cmd.py
+++ b/hermes_cli/sessions_cmd.py
@@ -73,6 +73,17 @@ def _export_dir(output) -> Path:
return Path(output).expanduser() if output and output != "-" else get_hermes_home() / "session-exports"
+def _output_file_in_dir(output, default_name: str):
+ """Single-file exports accept a directory too (``--help`` calls the positional a path, and md/qmd take
+ one): an existing directory, or one spelled with a trailing separator, means ``/``."""
+ if not output or output == "-":
+ return output
+ if output.endswith(("/", os.sep)) or os.path.isdir(output):
+ os.makedirs(output, exist_ok=True)
+ return os.path.join(output, default_name)
+ return output
+
+
def _write_output(output, text, summary) -> None:
"""Write to stdout when *output* is empty or ``-``; else to the file + print *summary*."""
if not output or output == "-":
@@ -375,6 +386,10 @@ def _export_flat(kind, args, collect):
return
sessions = collect()
if sessions is not None:
+ from hermes_cli.session_export import default_save_filename
+ name = (default_save_filename(sessions[0].get("id", ""), args.format) if len(sessions) == 1
+ else f"hermes_sessions.{args.format}")
+ args.output = _output_file_in_dir(args.output, name)
_write_output(args.output, *render(args, sessions))
@@ -421,6 +436,7 @@ def _export_trace(db, args, filters):
if not jsonl:
print(f"No transcript to export for session '{ids[0]}'.")
return
+ args.output = _output_file_in_dir(args.output, f"{ids[0]}.trace.jsonl")
_write_output(args.output, jsonl, f"Exported 1 session trace to {args.output}")
else:
out_dir = _export_dir(args.output)
diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py
index 8782c2e051..e235c20a9e 100644
--- a/hermes_cli/subcommands/sessions.py
+++ b/hermes_cli/subcommands/sessions.py
@@ -68,9 +68,10 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None:
sessions_export = sessions_subparsers.add_parser(
"export", help="Export sessions to JSONL, Markdown, or QMD")
- sessions_export.add_argument("output", nargs="?",
- help="Output path. JSONL: file path (use - for stdout, required). "
- "md/qmd: output directory (default: /session-exports)")
+ sessions_export.add_argument("output", nargs="?", metavar="OUTPUT",
+ help="Where to write. jsonl/html/trace: a file path, or a directory (existing, or ending in /) "
+ "to write a default-named file into; - for stdout (jsonl/trace only; jsonl requires OUTPUT). "
+ "md/qmd: a directory, one file per session (default: /session-exports)")
sessions_export.add_argument(
"--format", choices=["jsonl", "md", "qmd", "html", "trace"], default="jsonl",
help="Export format (default: jsonl). 'trace' emits Claude Code JSONL "
diff --git a/tests/hermes_cli/test_sessions_export_output_dir.py b/tests/hermes_cli/test_sessions_export_output_dir.py
index bbfedb5572..0eaf01df85 100644
--- a/tests/hermes_cli/test_sessions_export_output_dir.py
+++ b/tests/hermes_cli/test_sessions_export_output_dir.py
@@ -40,4 +40,4 @@ def test_jsonl_export_to_file_path_is_unchanged(monkeypatch, tmp_path):
_export(monkeypatch, str(target))
assert json.loads(target.read_text(encoding="utf-8"))["id"] == "sess-123"
- assert sorted(p.name for p in tmp_path.iterdir()) == ["one.jsonl"]
+ assert target.is_file() and not (tmp_path / "hermes_session_sess-123.jsonl").exists()
diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md
index e17fc4964e..5b8d70245c 100644
--- a/website/docs/user-guide/sessions.md
+++ b/website/docs/user-guide/sessions.md
@@ -366,6 +366,10 @@ hermes sessions export telegram-history.jsonl --source telegram
# Export a single session
hermes sessions export session.jsonl --session-id 20250305_091523_a1b2c3d4
+# Point at a directory (existing, or ending in /) and the file is named for you:
+# ~/exports/hermes_session_20250305_091523_a1b2c3d4.jsonl
+hermes sessions export ~/exports/ --session-id 20250305_091523_a1b2c3d4
+
# Redact API keys/tokens/credentials from the exported content
hermes sessions export backup.jsonl --redact
```