Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
21 changes: 21 additions & 0 deletions scripts/export.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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)
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 @@ -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)
Expand Down Expand Up @@ -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):
Expand Down
114 changes: 114 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,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")) == []
Loading