Skip to content

Commit 3ab5ac1

Browse files
feat(cli): non-zero exit codes on partial export failure
1 parent 5e4df43 commit 3ab5ac1

3 files changed

Lines changed: 143 additions & 0 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,14 @@ python scripts/export.py --project boost-capy
9393

9494
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`.
9595

96+
**Exit codes** (`export` subcommand; stderr prints `Exported N of M sessions (K failed)` when any session was attempted):
97+
98+
| Code | Meaning |
99+
|------|---------|
100+
| 0 | All attempted sessions exported successfully, or nothing to export with no errors |
101+
| 1 | Total failure — no sessions exported, one or more errors |
102+
| 2 | Partial failure — some sessions exported, some failed |
103+
96104
## Data Source
97105

98106
Reads from `~/.claude/projects/` which contains JSONL session files created by Claude Code.

scripts/export.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
export.py --format json --no-zip # JSON files instead of zip
1111
export.py --since incremental # only sessions new/changed since last run (mtime)
1212
export.py --since last # all sessions active on latest UTC calendar day
13+
14+
Exit codes (export subcommand):
15+
0 — all sessions exported successfully (or nothing to export, no errors)
16+
1 — total failure (no sessions exported; one or more errors)
17+
2 — partial failure (some sessions exported, some failed)
1318
"""
1419

1520
import argparse
@@ -33,6 +38,7 @@
3338
from utils.exclusion_rules import resolve_exclusion_rules_path, load_rules
3439
from utils.slugify import slugify
3540
from utils.export_engine import (
41+
BulkExportResult,
3642
ExportFormat,
3743
NoopSink,
3844
SinceMode,
@@ -393,6 +399,19 @@ def _aggregate_stats(base_dir: str, project_filter: str, fmt: str):
393399
print(f" Est. cost: ~${totals['total_cost']:.2f} USD")
394400

395401

402+
def _exit_bulk_export(result: BulkExportResult) -> None:
403+
"""Map bulk-export counts to process exit code (CLI wrapper only)."""
404+
n = result.exported_session_count
405+
m = result.total_candidates
406+
k = result.failure_count
407+
if m > 0 or n > 0 or k > 0:
408+
print(f"Exported {n} of {m} sessions ({k} failed)", file=sys.stderr)
409+
if n == 0 and k > 0:
410+
sys.exit(1)
411+
if k > 0:
412+
sys.exit(2)
413+
414+
396415
def cmd_export(args):
397416
"""The main export command. Writes md/json files, optionally zipped."""
398417
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:
494513
"All sessions on disk were already at or before the last "
495514
"recorded export time (nothing new to write)."
496515
)
516+
_exit_bulk_export(export_result)
497517
return
498518

499519
os.makedirs(out_dir, exist_ok=True)
@@ -526,6 +546,7 @@ def _on_export_error(sid: str, exc: Exception) -> None:
526546

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

530551

531552
def _export_single(session: dict, stats: dict, fmt: str, out_dir: str):
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""CLI export exit codes for bulk export (partial / total failure)."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import sys
7+
import types
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
REPO_ROOT = Path(__file__).resolve().parent.parent
13+
sys.path.insert(0, str(REPO_ROOT))
14+
15+
import scripts.export as export # noqa: E402
16+
from tests.test_cli_e2e import _run_cli, _seed_base_dir # noqa: E402
17+
from utils.jsonl_parser import parse_session # noqa: E402
18+
19+
_SUMMARY_RE = re.compile(
20+
r"Exported \d+ of \d+ sessions \(\d+ failed\)",
21+
)
22+
23+
24+
def _export_args(tmp_path: Path, base: Path, out_dir: Path) -> types.SimpleNamespace:
25+
return types.SimpleNamespace(
26+
base_dir=str(base),
27+
out=str(out_dir),
28+
since="all",
29+
no_zip=True,
30+
project=None,
31+
format="md",
32+
session=None,
33+
exclude_rules=None,
34+
)
35+
36+
37+
def test_cli_export_clean_exits_zero(tmp_path):
38+
base = _seed_base_dir(tmp_path)
39+
out_dir = tmp_path / "out"
40+
proc = _run_cli([
41+
"export",
42+
"--base-dir",
43+
str(base),
44+
"--since",
45+
"all",
46+
"--no-zip",
47+
"--out",
48+
str(out_dir),
49+
])
50+
assert proc.returncode == 0, proc.stderr
51+
assert list(out_dir.rglob("*.md"))
52+
if proc.stderr.strip():
53+
assert "failed" not in proc.stderr.lower() or "0 failed" in proc.stderr
54+
55+
56+
def test_cli_export_partial_failure_exits_two(
57+
tmp_path, monkeypatch, capsys
58+
):
59+
"""One session exports; a second fails parse (simulated corrupt file)."""
60+
base = _seed_base_dir(tmp_path)
61+
project_dir = next(base.iterdir())
62+
bad = project_dir / "session_bad.jsonl"
63+
bad.write_text('{"type": "user"}\n', encoding="utf-8")
64+
out_dir = tmp_path / "out"
65+
66+
state_dir = tmp_path / "state"
67+
state_dir.mkdir()
68+
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
69+
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))
70+
71+
real_parse = parse_session
72+
73+
def _parse(path: str):
74+
if bad.name in path.replace("\\", "/"):
75+
raise ValueError("simulated corrupt jsonl")
76+
return real_parse(path)
77+
78+
monkeypatch.setattr("utils.export_engine.parse_session", _parse)
79+
80+
with pytest.raises(SystemExit) as exc_info:
81+
export.cmd_export(_export_args(tmp_path, base, out_dir))
82+
83+
assert exc_info.value.code == 2
84+
captured = capsys.readouterr()
85+
assert _SUMMARY_RE.search(captured.err), captured.err
86+
assert "Exported 1 of 2 sessions (1 failed)" in captured.err
87+
assert len(list(out_dir.rglob("*.md"))) == 1
88+
89+
90+
def test_cli_export_total_failure_exits_one(tmp_path, monkeypatch, capsys):
91+
project_dir = tmp_path / "test-project"
92+
project_dir.mkdir(parents=True)
93+
(project_dir / "bad_a.jsonl").write_text("{}", encoding="utf-8")
94+
(project_dir / "bad_b.jsonl").write_text("{}", encoding="utf-8")
95+
out_dir = tmp_path / "out"
96+
97+
state_dir = tmp_path / "state"
98+
state_dir.mkdir()
99+
monkeypatch.setattr(export, "STATE_FILE", str(state_dir / "export_state.json"))
100+
monkeypatch.setattr(export, "STATE_DIR", str(state_dir))
101+
102+
def _parse(_path: str):
103+
raise ValueError("simulated corrupt jsonl")
104+
105+
monkeypatch.setattr("utils.export_engine.parse_session", _parse)
106+
107+
with pytest.raises(SystemExit) as exc_info:
108+
export.cmd_export(_export_args(tmp_path, tmp_path, out_dir))
109+
110+
assert exc_info.value.code == 1
111+
captured = capsys.readouterr()
112+
assert "Exported 0 of 2 sessions (2 failed)" in captured.err
113+
assert "Nothing to export." in captured.out
114+
assert list(out_dir.rglob("*.md")) == []

0 commit comments

Comments
 (0)