diff --git a/README.md b/README.md index c05e6bd..63881ec 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/scripts/export.py b/scripts/export.py index fd853e7..7db67ff 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -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 @@ -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, @@ -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) + + 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() @@ -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()} — " @@ -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 @@ -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) @@ -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): diff --git a/tests/test_cli_export_exit_codes.py b/tests/test_cli_export_exit_codes.py new file mode 100644 index 0000000..26c2483 --- /dev/null +++ b/tests/test_cli_export_exit_codes.py @@ -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( + 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")) == []