From 3ab5ac11052cb4e70d0d9522353c26266eede8a4 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 28 May 2026 02:52:07 +0800 Subject: [PATCH 1/4] feat(cli): non-zero exit codes on partial export failure --- README.md | 8 ++ scripts/export.py | 21 +++++ tests/test_cli_export_exit_codes.py | 114 ++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 tests/test_cli_export_exit_codes.py 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..9de53d6 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -10,6 +10,11 @@ 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) """ import argparse @@ -33,6 +38,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 +399,19 @@ 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).""" + n = result.exported_session_count + m = result.total_candidates + k = result.failure_count + if m > 0 or n > 0 or k > 0: + print(f"Exported {n} of {m} sessions ({k} failed)", file=sys.stderr) + if n == 0 and k > 0: + sys.exit(1) + if k > 0: + 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() @@ -494,6 +513,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 +546,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..e0690db --- /dev/null +++ b/tests/test_cli_export_exit_codes.py @@ -0,0 +1,114 @@ +"""CLI export exit codes for bulk export (partial / total failure).""" + +from __future__ import annotations + +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.jsonl_parser import parse_session # noqa: E402 + +_SUMMARY_RE = re.compile( + r"Exported \d+ of \d+ sessions \(\d+ failed\)", +) + + +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")) + if proc.stderr.strip(): + assert "failed" not in proc.stderr.lower() or "0 failed" in proc.stderr + + +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 = next(base.iterdir()) + 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_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")) == [] From e947ced3605b5fd2e41797ce449e828043035712 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 28 May 2026 03:23:53 +0800 Subject: [PATCH 2/4] fix(cli): apply exit codes on --since last early returns Call _exit_bulk_export(export_result) when cmd_export returns early for latest_day is None or zero overlap, matching the empty-export path so recorded failures map to exit 1/2 instead of always exiting 0. --- scripts/export.py | 2 ++ tests/test_cli_export_exit_codes.py | 46 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/scripts/export.py b/scripts/export.py index 9de53d6..4f37de7 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -479,6 +479,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()} — " @@ -489,6 +490,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 diff --git a/tests/test_cli_export_exit_codes.py b/tests/test_cli_export_exit_codes.py index e0690db..7ddccdc 100644 --- a/tests/test_cli_export_exit_codes.py +++ b/tests/test_cli_export_exit_codes.py @@ -14,6 +14,7 @@ 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( @@ -87,6 +88,51 @@ def _parse(path: str): 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) + if result.failure_count > 0: + raise SystemExit(1) + + fake_result = BulkExportResult( + total_candidates=3, + failure_count=1, + 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, + ) + + with pytest.raises(SystemExit) as exc_info: + export.cmd_export(args) + + assert exc_info.value.code == 1 + assert len(exit_calls) == 1 + assert exit_calls[0] is fake_result + assert "no qualifying sessions" in capsys.readouterr().out.lower() + + def test_cli_export_total_failure_exits_one(tmp_path, monkeypatch, capsys): project_dir = tmp_path / "test-project" project_dir.mkdir(parents=True) From 719fb9a52c749ffd641a1362077078d6edba2dae Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 28 May 2026 05:11:58 +0800 Subject: [PATCH 3/4] fix(cli): skip stderr summary on incremental no-op exports Only print the Exported N of M summary when exported_session_count or failure_count is non-zero, so clean --since incremental cron runs with unchanged mtimes stay silent on stderr. Add subprocess test for the no-op path; tighten since-last early-return test to realistic 0/0 counts. --- scripts/export.py | 2 +- tests/test_cli_export_exit_codes.py | 46 ++++++++++++++++++++++------- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/export.py b/scripts/export.py index 4f37de7..c98ad7e 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -404,7 +404,7 @@ def _exit_bulk_export(result: BulkExportResult) -> None: n = result.exported_session_count m = result.total_candidates k = result.failure_count - if m > 0 or n > 0 or k > 0: + if n > 0 or k > 0: print(f"Exported {n} of {m} sessions ({k} failed)", file=sys.stderr) if n == 0 and k > 0: sys.exit(1) diff --git a/tests/test_cli_export_exit_codes.py b/tests/test_cli_export_exit_codes.py index 7ddccdc..efc6be9 100644 --- a/tests/test_cli_export_exit_codes.py +++ b/tests/test_cli_export_exit_codes.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import re import sys import types @@ -22,6 +23,12 @@ ) +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), @@ -96,14 +103,8 @@ def test_since_last_early_return_invokes_exit_bulk_export( def _track_exit(result: BulkExportResult) -> None: exit_calls.append(result) - if result.failure_count > 0: - raise SystemExit(1) - fake_result = BulkExportResult( - total_candidates=3, - failure_count=1, - latest_day=None, - ) + fake_result = BulkExportResult(latest_day=None) monkeypatch.setattr(export, "_exit_bulk_export", _track_exit) monkeypatch.setattr( @@ -124,13 +125,36 @@ def _track_exit(result: BulkExportResult) -> None: exclude_rules=None, ) - with pytest.raises(SystemExit) as exc_info: - export.cmd_export(args) + export.cmd_export(args) - assert exc_info.value.code == 1 assert len(exit_calls) == 1 assert exit_calls[0] is fake_result - assert "no qualifying sessions" in capsys.readouterr().out.lower() + captured = capsys.readouterr() + assert "no qualifying sessions" in captured.out.lower() + assert "Exported" not 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): From fa8564b2d4abf4b5414119887e258aeedba799c2 Mon Sep 17 00:00:00 2001 From: chen Date: Thu, 28 May 2026 05:28:22 +0800 Subject: [PATCH 4/4] fix(cli): accurate attempt count, elif, stdout on success in exit summary - m = n + k (exported + failed) instead of total_candidates; avoids misleading counts for incremental no-ops and --since last date-filtered sessions - Use elif so mutual exclusion of exit-1/exit-2 is explicit - Route summary to stdout when k==0 (clean success), stderr when k>0; prevents cron monitors treating clean exports as failures - Add test_since_last_early_return_exits_one_on_failure: validates actual exit code 1 without mocking _exit_bulk_export - Use explicit base/test-project instead of next(base.iterdir()) - Add comment on sys.exit branches and --session doc note in docstring --- scripts/export.py | 17 +++++++++----- tests/test_cli_export_exit_codes.py | 35 ++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/scripts/export.py b/scripts/export.py index c98ad7e..7db67ff 100644 --- a/scripts/export.py +++ b/scripts/export.py @@ -15,6 +15,7 @@ 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 @@ -400,15 +401,21 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str): def _exit_bulk_export(result: BulkExportResult) -> None: - """Map bulk-export counts to process exit code (CLI wrapper only).""" + """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 - m = result.total_candidates k = result.failure_count + # "attempted" = exported + failed; excludes untitled/excluded/mtime-skipped + m = n + k if n > 0 or k > 0: - print(f"Exported {n} of {m} sessions ({k} failed)", file=sys.stderr) - if n == 0 and 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) - if k > 0: + elif k > 0: # partial failure sys.exit(2) diff --git a/tests/test_cli_export_exit_codes.py b/tests/test_cli_export_exit_codes.py index efc6be9..26c2483 100644 --- a/tests/test_cli_export_exit_codes.py +++ b/tests/test_cli_export_exit_codes.py @@ -57,8 +57,9 @@ def test_cli_export_clean_exits_zero(tmp_path): ]) assert proc.returncode == 0, proc.stderr assert list(out_dir.rglob("*.md")) - if proc.stderr.strip(): - assert "failed" not in proc.stderr.lower() or "0 failed" in proc.stderr + # 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( @@ -66,7 +67,7 @@ def test_cli_export_partial_failure_exits_two( ): """One session exports; a second fails parse (simulated corrupt file).""" base = _seed_base_dir(tmp_path) - project_dir = next(base.iterdir()) + 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" @@ -134,6 +135,34 @@ def _track_exit(result: BulkExportResult) -> None: 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)