Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ python scripts/export.py --project boost-capy

The `--project` flag matches a **case-insensitive substring** of either the **Project** column from `list` (derived from the session working directory) or the internal directory name under `~/.claude/projects/` (for example `F--boost-capy` or `d--harbor-forge`). A substring like `boost-capy` matches `F--boost-capy`; you can also paste the friendly name shown in `list`.

**Exit codes** (`export` subcommand; stderr prints `Exported N of M sessions (K failed)` when any session was attempted):

| Code | Meaning |
|------|---------|
| 0 | All attempted sessions exported successfully, or nothing to export with no errors |
| 1 | Total failure — no sessions exported, one or more errors |
| 2 | Partial failure — some sessions exported, some failed |

## Data Source

Reads from `~/.claude/projects/` which contains JSONL session files created by Claude Code.
Expand Down
30 changes: 30 additions & 0 deletions scripts/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
export.py --format json --no-zip # JSON files instead of zip
export.py --since incremental # only sessions new/changed since last run (mtime)
export.py --since last # all sessions active on latest UTC calendar day

Exit codes (export subcommand):
0 — all sessions exported successfully (or nothing to export, no errors)
1 — total failure (no sessions exported; one or more errors)
2 — partial failure (some sessions exported, some failed)
(exit codes apply to bulk export only; --session single-export always exits 0)
"""

import argparse
Expand All @@ -33,6 +39,7 @@
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
from utils.slugify import slugify
from utils.export_engine import (
BulkExportResult,
ExportFormat,
NoopSink,
SinceMode,
Expand Down Expand Up @@ -393,6 +400,25 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
print(f" Est. cost: ~${totals['total_cost']:.2f} USD")


def _exit_bulk_export(result: BulkExportResult) -> None:
"""Map bulk-export counts to process exit code (CLI wrapper only).

Prints a summary to stderr on any failure, stdout on clean success.
Raises SystemExit(1) for total failure, SystemExit(2) for partial.
"""
n = result.exported_session_count
k = result.failure_count
# "attempted" = exported + failed; excludes untitled/excluded/mtime-skipped
m = n + k
if n > 0 or k > 0:
dest = sys.stderr if k > 0 else sys.stdout
print(f"Exported {n} of {m} sessions ({k} failed)", file=dest)
if n == 0 and k > 0: # total failure
sys.exit(1)
elif k > 0: # partial failure
sys.exit(2)
Comment thread
clean6378-max-it marked this conversation as resolved.


def cmd_export(args):
"""The main export command. Writes md/json files, optionally zipped."""
base_dir = getattr(args, "base_dir", None) or get_claude_projects_dir()
Expand Down Expand Up @@ -460,6 +486,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
if since == "last":
if latest_day is None:
print("Nothing to export (no qualifying sessions in scope).")
_exit_bulk_export(export_result)
return
print(
f"Latest activity end-date (UTC): {latest_day.isoformat()} — "
Expand All @@ -470,6 +497,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
f"No sessions overlap {latest_day.isoformat()} (UTC); "
"nothing to export."
)
_exit_bulk_export(export_result)
return
elif since == "incremental":
skipped_mtime_unchanged = export_result.skipped_mtime_unchanged_count
Expand All @@ -494,6 +522,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
"All sessions on disk were already at or before the last "
"recorded export time (nothing new to write)."
)
_exit_bulk_export(export_result)
return

os.makedirs(out_dir, exist_ok=True)
Expand Down Expand Up @@ -526,6 +555,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:

_save_state(last_export, count=len(manifest), out_dir=out_dir)
print(f"State saved to {STATE_FILE}")
_exit_bulk_export(export_result)


def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
Expand Down
213 changes: 213 additions & 0 deletions tests/test_cli_export_exit_codes.py
Comment thread
clean6378-max-it marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"""CLI export exit codes for bulk export (partial / total failure)."""

from __future__ import annotations

import os
import re
import sys
import types
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT))

import scripts.export as export # noqa: E402
from tests.test_cli_e2e import _run_cli, _seed_base_dir # noqa: E402
from utils.export_engine import BulkExportResult # noqa: E402
from utils.jsonl_parser import parse_session # noqa: E402

_SUMMARY_RE = re.compile(
r"Exported \d+ of \d+ sessions \(\d+ failed\)",
)


def _isolated_home_env(tmp_path: Path) -> dict[str, str]:
"""Redirect ~/.claude-code-chat-browser export state for subprocess CLI runs."""
home = str(tmp_path / "home")
return {"HOME": home, "USERPROFILE": home}


def _export_args(tmp_path: Path, base: Path, out_dir: Path) -> types.SimpleNamespace:
return types.SimpleNamespace(
base_dir=str(base),
out=str(out_dir),
since="all",
no_zip=True,
project=None,
format="md",
session=None,
exclude_rules=None,
)


def test_cli_export_clean_exits_zero(tmp_path):
base = _seed_base_dir(tmp_path)
out_dir = tmp_path / "out"
proc = _run_cli([
"export",
"--base-dir",
str(base),
"--since",
"all",
"--no-zip",
"--out",
str(out_dir),
])
assert proc.returncode == 0, proc.stderr
assert list(out_dir.rglob("*.md"))
# Success summary must go to stdout, not stderr
assert "Exported" not in proc.stderr
assert "Exported 1 of 1 sessions (0 failed)" in proc.stdout


def test_cli_export_partial_failure_exits_two(
tmp_path, monkeypatch, capsys
):
"""One session exports; a second fails parse (simulated corrupt file)."""
base = _seed_base_dir(tmp_path)
project_dir = base / "test-project"
bad = project_dir / "session_bad.jsonl"
bad.write_text('{"type": "user"}\n', encoding="utf-8")
out_dir = tmp_path / "out"

state_dir = tmp_path / "state"
state_dir.mkdir()
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))

real_parse = parse_session

def _parse(path: str):
if bad.name in path.replace("\\", "/"):
raise ValueError("simulated corrupt jsonl")
return real_parse(path)

monkeypatch.setattr("utils.export_engine.parse_session", _parse)

with pytest.raises(SystemExit) as exc_info:
export.cmd_export(_export_args(tmp_path, base, out_dir))

assert exc_info.value.code == 2
captured = capsys.readouterr()
assert _SUMMARY_RE.search(captured.err), captured.err
assert "Exported 1 of 2 sessions (1 failed)" in captured.err
assert len(list(out_dir.rglob("*.md"))) == 1


def test_since_last_early_return_invokes_exit_bulk_export(
Comment thread
clean6378-max-it marked this conversation as resolved.
tmp_path, monkeypatch, capsys
):
"""cmd_export --since last must call _exit_bulk_export on early-return paths."""
exit_calls: list[BulkExportResult] = []

def _track_exit(result: BulkExportResult) -> None:
exit_calls.append(result)

fake_result = BulkExportResult(latest_day=None)

monkeypatch.setattr(export, "_exit_bulk_export", _track_exit)
monkeypatch.setattr(
export,
"run_bulk_export",
lambda **kwargs: fake_result,
)
monkeypatch.setattr(export, "list_projects", lambda base: [{"name": "p", "path": "/p"}])

args = types.SimpleNamespace(
base_dir=str(tmp_path),
out=str(tmp_path / "out"),
since="last",
no_zip=True,
project=None,
format="md",
session=None,
exclude_rules=None,
)

export.cmd_export(args)

assert len(exit_calls) == 1
assert exit_calls[0] is fake_result
captured = capsys.readouterr()
assert "no qualifying sessions" in captured.out.lower()
assert "Exported" not in captured.err


def test_since_last_early_return_exits_one_on_failure(
tmp_path, monkeypatch, capsys
):
"""Since-last early-return with failure_count>0 must produce real exit code 1."""
fake_result = BulkExportResult(latest_day=None, failure_count=1)

monkeypatch.setattr(export, "run_bulk_export", lambda **kwargs: fake_result)
monkeypatch.setattr(export, "list_projects", lambda base: [{"name": "p", "path": "/p"}])

args = types.SimpleNamespace(
base_dir=str(tmp_path),
out=str(tmp_path / "out"),
since="last",
no_zip=True,
project=None,
format="md",
session=None,
exclude_rules=None,
)

with pytest.raises(SystemExit) as exc_info:
export.cmd_export(args)

assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "Exported 0 of 1 sessions (1 failed)" in captured.err


def test_cli_export_incremental_noop_no_stderr_summary(tmp_path):
"""Second incremental run after state is saved: exit 0, no stderr summary."""
base = _seed_base_dir(tmp_path)
out_dir = tmp_path / "out"
home_env = _isolated_home_env(tmp_path)
argv = [
"export",
"--base-dir",
str(base),
"--no-zip",
"--out",
str(out_dir),
]
first = _run_cli([*argv, "--since", "all"], env=home_env)
assert first.returncode == 0, first.stderr
assert list(out_dir.rglob("*.md"))

second = _run_cli([*argv, "--since", "incremental"], env=home_env)
assert second.returncode == 0, second.stderr
assert "Exported" not in second.stderr
assert "Nothing to export" in second.stdout


def test_cli_export_total_failure_exits_one(tmp_path, monkeypatch, capsys):
project_dir = tmp_path / "test-project"
project_dir.mkdir(parents=True)
(project_dir / "bad_a.jsonl").write_text("{}", encoding="utf-8")
(project_dir / "bad_b.jsonl").write_text("{}", encoding="utf-8")
out_dir = tmp_path / "out"

state_dir = tmp_path / "state"
state_dir.mkdir()
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))

def _parse(_path: str):
raise ValueError("simulated corrupt jsonl")

monkeypatch.setattr("utils.export_engine.parse_session", _parse)

with pytest.raises(SystemExit) as exc_info:
export.cmd_export(_export_args(tmp_path, tmp_path, out_dir))

assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "Exported 0 of 2 sessions (2 failed)" in captured.err
assert "Nothing to export." in captured.out
assert list(out_dir.rglob("*.md")) == []
Loading