fix(cli): sessions export accepts a directory for single-file formats

`hermes sessions export --session-id X <dir>/` 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_<id>.<fmt>`), and the help text spells out per-format what
OUTPUT means.
This commit is contained in:
teknium1
2026-09-13 12:35:42 -07:00
committed by Teknium
parent 4003840378
commit e30d0639bb
4 changed files with 25 additions and 4 deletions

View File

@@ -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 ``<dir>/<default_name>``."""
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)

View File

@@ -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: <hermes home>/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: <hermes home>/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 "

View File

@@ -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()

View File

@@ -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
```