Skip to content

Commit 13968a2

Browse files
committed
Add claude-backup CLI implementation
- Click-based CLI: list, export, export-all - Modules: scanner (project discovery), parser (JSONL), exporter (Markdown) - Test suite: 42 tests, 93% coverage - GitHub Actions CI for Python 3.10/3.11/3.12 - Test fixtures with edge cases: unicode, malformed JSON, empty files, missing index
1 parent 82df5f5 commit 13968a2

22 files changed

Lines changed: 1206 additions & 0 deletions

.github/workflows/test.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
fail-fast: false
14+
matrix:
15+
python-version: ["3.10", "3.11", "3.12"]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
25+
- name: Install package + dev deps
26+
run: |
27+
python -m pip install --upgrade pip
28+
pip install -e ".[dev]"
29+
30+
- name: Run pytest
31+
run: pytest -v --cov=claude_backup --cov-report=term-missing
32+
33+
- name: Smoke-test CLI
34+
run: claude-backup --help

.gitignore

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Real Claude data — never commit
2+
.claude/
3+
**/.claude/projects/
4+
5+
# Local backup output
6+
backups/
7+
/backups/
8+
9+
# Python
10+
__pycache__/
11+
*.py[cod]
12+
*$py.class
13+
*.egg-info/
14+
*.egg
15+
.eggs/
16+
build/
17+
dist/
18+
.pytest_cache/
19+
.coverage
20+
.coverage.*
21+
htmlcov/
22+
coverage.xml
23+
.tox/
24+
.venv/
25+
venv/
26+
env/
27+
28+
# Editors / OS
29+
.DS_Store
30+
.idea/
31+
.vscode/
32+
*.swp

claude_backup/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""claude-backup: Export Claude Code session history to Markdown."""
2+
3+
__version__ = "0.1.0"

claude_backup/cli.py

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""CLI entry point for claude-backup."""
2+
3+
from __future__ import annotations
4+
5+
import sys
6+
from pathlib import Path
7+
8+
import click
9+
10+
from . import __version__
11+
from .exporter import export_session
12+
from .scanner import ProjectInfo, get_claude_home, scan_projects
13+
14+
15+
@click.group(help="Export Claude Code session history to Markdown.")
16+
@click.version_option(__version__, prog_name="claude-backup")
17+
@click.option(
18+
"--claude-home",
19+
type=click.Path(path_type=Path),
20+
default=None,
21+
help="Override the Claude projects root (default: ~/.claude/projects).",
22+
)
23+
@click.pass_context
24+
def main(ctx: click.Context, claude_home: Path | None) -> None:
25+
ctx.ensure_object(dict)
26+
ctx.obj["claude_home"] = claude_home
27+
28+
29+
@main.command("list", help="List all discovered sessions.")
30+
@click.pass_context
31+
def list_cmd(ctx: click.Context) -> None:
32+
projects = _safe_scan(ctx.obj.get("claude_home"))
33+
rows = _flatten_sessions(projects)
34+
35+
if not rows:
36+
click.echo("No sessions found.")
37+
return
38+
39+
headers = ["Project", "Session ID", "First Prompt", "Msg Count", "Created", "Git Branch"]
40+
table_rows = [
41+
[
42+
r.project,
43+
r.session_id,
44+
_truncate(r.first_prompt, 40),
45+
str(r.message_count),
46+
r.created or "-",
47+
r.git_branch or "-",
48+
]
49+
for r in rows
50+
]
51+
click.echo(_format_table(headers, table_rows))
52+
53+
54+
@main.command("export", help="Export a single session by ID.")
55+
@click.argument("session_id")
56+
@click.option(
57+
"--output",
58+
"-o",
59+
type=click.Path(path_type=Path),
60+
default=Path("./backups"),
61+
help="Output directory (default: ./backups/).",
62+
)
63+
@click.pass_context
64+
def export_cmd(ctx: click.Context, session_id: str, output: Path) -> None:
65+
projects = _safe_scan(ctx.obj.get("claude_home"))
66+
target = None
67+
for project in projects:
68+
for session in project.sessions:
69+
if session.session_id == session_id:
70+
target = session
71+
break
72+
if target:
73+
break
74+
75+
if target is None:
76+
click.echo(f"Session not found: {session_id}", err=True)
77+
sys.exit(2)
78+
79+
if target.jsonl_path is None or not target.jsonl_path.exists():
80+
click.echo(
81+
f"Session {session_id} has no JSONL file on disk.", err=True
82+
)
83+
sys.exit(2)
84+
85+
out = export_session(target, output)
86+
click.echo(f"Exported: {out}")
87+
88+
89+
@main.command("export-all", help="Export every discovered session.")
90+
@click.option(
91+
"--output",
92+
"-o",
93+
type=click.Path(path_type=Path),
94+
default=Path("./backups"),
95+
help="Output directory (default: ./backups/).",
96+
)
97+
@click.pass_context
98+
def export_all_cmd(ctx: click.Context, output: Path) -> None:
99+
projects = _safe_scan(ctx.obj.get("claude_home"))
100+
exported = 0
101+
skipped = 0
102+
103+
for project in projects:
104+
project_out = output / project.name
105+
for session in project.sessions:
106+
if session.jsonl_path is None or not session.jsonl_path.exists():
107+
click.echo(
108+
f"Skip {project.name}/{session.session_id}: no JSONL file",
109+
err=True,
110+
)
111+
skipped += 1
112+
continue
113+
try:
114+
path = export_session(session, project_out)
115+
click.echo(f"Exported: {path}")
116+
exported += 1
117+
except Exception as e: # pragma: no cover - defensive
118+
click.echo(
119+
f"Failed {project.name}/{session.session_id}: {e}", err=True
120+
)
121+
skipped += 1
122+
123+
click.echo(f"\nDone. Exported: {exported}, skipped: {skipped}")
124+
125+
126+
def _safe_scan(claude_home: Path | None) -> list[ProjectInfo]:
127+
try:
128+
return scan_projects(claude_home)
129+
except FileNotFoundError:
130+
root = get_claude_home(claude_home)
131+
click.echo(
132+
f"Error: Claude projects directory not found at {root}.\n"
133+
f"Have you used Claude Code on this machine?",
134+
err=True,
135+
)
136+
sys.exit(1)
137+
except NotADirectoryError as e:
138+
click.echo(f"Error: {e}", err=True)
139+
sys.exit(1)
140+
141+
142+
def _flatten_sessions(projects):
143+
rows = []
144+
for project in projects:
145+
for session in project.sessions:
146+
rows.append(session)
147+
return rows
148+
149+
150+
def _truncate(text: str, length: int) -> str:
151+
if not text:
152+
return "-"
153+
text = text.replace("\n", " ").strip()
154+
if len(text) <= length:
155+
return text
156+
return text[: length - 1] + "…"
157+
158+
159+
def _format_table(headers: list[str], rows: list[list[str]]) -> str:
160+
widths = [len(h) for h in headers]
161+
for row in rows:
162+
for i, cell in enumerate(row):
163+
widths[i] = max(widths[i], len(cell))
164+
165+
def line(cells: list[str]) -> str:
166+
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(cells))
167+
168+
sep = " ".join("-" * w for w in widths)
169+
return "\n".join([line(headers), sep, *[line(r) for r in rows]])
170+
171+
172+
if __name__ == "__main__":
173+
main()

claude_backup/exporter.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Exporter: Convert parsed sessions to Markdown files."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime, timezone
6+
from pathlib import Path
7+
8+
from .parser import Message, parse_session
9+
from .scanner import SessionInfo
10+
11+
12+
ROLE_LABELS = {
13+
"user": "User",
14+
"assistant": "Assistant",
15+
"tool_result": "Tool Result",
16+
"system": "System",
17+
}
18+
19+
20+
def export_session(
21+
session: SessionInfo,
22+
output_dir: Path,
23+
now: datetime | None = None,
24+
) -> Path:
25+
"""Export a single session to Markdown. Returns path to the written file."""
26+
if session.jsonl_path is None or not session.jsonl_path.exists():
27+
raise FileNotFoundError(
28+
f"Session JSONL not found for {session.session_id}"
29+
)
30+
31+
output_dir.mkdir(parents=True, exist_ok=True)
32+
messages = parse_session(session.jsonl_path)
33+
34+
date_prefix = _date_prefix(session, messages)
35+
filename = f"{date_prefix}--{session.session_id}.md"
36+
out_path = output_dir / filename
37+
38+
md = render_markdown(session, messages, now=now)
39+
out_path.write_text(md, encoding="utf-8")
40+
return out_path
41+
42+
43+
def render_markdown(
44+
session: SessionInfo,
45+
messages: list[Message],
46+
now: datetime | None = None,
47+
) -> str:
48+
"""Build the full Markdown document for a session."""
49+
now = now or datetime.now(timezone.utc)
50+
51+
branch = session.git_branch or _first_non_empty(m.git_branch for m in messages)
52+
model = _first_non_empty(m.model for m in messages)
53+
msg_count = session.message_count or len(messages)
54+
55+
frontmatter = _render_frontmatter(
56+
project=session.project,
57+
session_id=session.session_id,
58+
branch=branch,
59+
model=model,
60+
messages=msg_count,
61+
exported_at=_format_iso(now),
62+
)
63+
64+
title_branch = branch or "no-branch"
65+
header = f"# {session.project} / {title_branch} / {session.session_id}\n"
66+
67+
body = _render_body(messages)
68+
69+
parts = [frontmatter, "", header, body]
70+
return "\n".join(parts).rstrip() + "\n"
71+
72+
73+
def _render_frontmatter(**fields) -> str:
74+
lines = ["---"]
75+
for key, value in fields.items():
76+
lines.append(f"{key}: {_yaml_scalar(value)}")
77+
lines.append("---")
78+
return "\n".join(lines)
79+
80+
81+
_YAML_INDICATOR_PREFIXES = ("'", '"', "[", "{", "&", "*", "!", "|", ">", "%", "@", "`", "?", "-")
82+
83+
84+
def _yaml_scalar(value) -> str:
85+
if isinstance(value, int):
86+
return str(value)
87+
s = str(value) if value is not None else ""
88+
if s == "":
89+
return '""'
90+
needs_quoting = (
91+
"\n" in s
92+
or '"' in s
93+
or ": " in s
94+
or " #" in s
95+
or s.startswith(_YAML_INDICATOR_PREFIXES)
96+
or s.endswith(":")
97+
or s.strip() != s
98+
)
99+
if needs_quoting:
100+
escaped = s.replace("\\", "\\\\").replace('"', '\\"')
101+
return f'"{escaped}"'
102+
return s
103+
104+
105+
def _render_body(messages: list[Message]) -> str:
106+
if not messages:
107+
return "_No messages._\n"
108+
109+
sections: list[str] = []
110+
for msg in messages:
111+
label = ROLE_LABELS.get(msg.role, msg.role.capitalize())
112+
time_str = _format_short_time(msg.timestamp)
113+
heading = f"## {label} ({time_str})" if time_str else f"## {label}"
114+
body = msg.content.rstrip() if msg.content else "_(empty)_"
115+
sections.append(f"{heading}\n{body}\n")
116+
return "\n".join(sections)
117+
118+
119+
def _format_iso(dt: datetime) -> str:
120+
if dt.tzinfo is None:
121+
dt = dt.replace(tzinfo=timezone.utc)
122+
return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
123+
124+
125+
def _format_short_time(timestamp: str) -> str:
126+
if not timestamp:
127+
return ""
128+
try:
129+
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
130+
except ValueError:
131+
return timestamp
132+
return dt.strftime("%H:%M:%S")
133+
134+
135+
def _date_prefix(session: SessionInfo, messages: list[Message]) -> str:
136+
candidate = session.created or _first_non_empty(m.timestamp for m in messages)
137+
if candidate:
138+
try:
139+
dt = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
140+
return dt.strftime("%Y-%m-%d")
141+
except ValueError:
142+
pass
143+
return datetime.now(timezone.utc).strftime("%Y-%m-%d")
144+
145+
146+
def _first_non_empty(values) -> str:
147+
for v in values:
148+
if v:
149+
return v
150+
return ""

0 commit comments

Comments
 (0)