Skip to content

Instantly share code, notes, and snippets.

@Kabilan108
Created January 31, 2026 23:58
Show Gist options
  • Select an option

  • Save Kabilan108/7b0912cee5c89efb43ed2e0dcf6ac8e0 to your computer and use it in GitHub Desktop.

Select an option

Save Kabilan108/7b0912cee5c89efb43ed2e0dcf6ac8e0 to your computer and use it in GitHub Desktop.
Claude Code Session Export Tool (cctrace)
#!/usr/bin/env -S uv --quiet run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Claude Code Session Export Tool
Exports Claude Code sessions to structured output directories.
"""
import argparse
import getpass
import json
import os
import re
import shutil
import sys
import xml.etree.ElementTree as ET
from datetime import datetime
from pathlib import Path
from typing import TypedDict
from xml.dom import minidom
CCTRACE_VERSION = "3.0.0"
def clean_text_for_xml(text: str) -> str:
if not text:
return text
return re.sub(r"[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]", "", str(text))
def get_normalized_project_dir(project_path: str) -> str:
project_path = str(project_path)
if os.name == "nt":
project_dir_name = (
project_path.replace("\\", "-")
.replace(":", "-")
.replace("/", "-")
.replace(".", "-")
.replace("_", "-")
)
else:
normalized = project_path.replace("\\", "/")
project_dir_name = (
normalized.replace("/", "-").replace(".", "-").replace("_", "-")
)
if project_dir_name.startswith("-"):
project_dir_name = project_dir_name[1:]
if os.name == "nt":
return project_dir_name
return f"-{project_dir_name}"
def find_project_sessions(project_path: str) -> list[dict]:
normalized_dir = get_normalized_project_dir(project_path)
claude_project_dir = Path.home() / ".claude" / "projects" / normalized_dir
if not claude_project_dir.exists():
return []
jsonl_files = []
for file in claude_project_dir.glob("*.jsonl"):
if file.name.startswith("agent-"):
continue
stat = file.stat()
jsonl_files.append(
{"path": file, "mtime": stat.st_mtime, "session_id": file.stem}
)
return sorted(jsonl_files, key=lambda x: x["mtime"], reverse=True)
class SessionMetadata(TypedDict):
session_id: str | None
start_time: str | None
end_time: str | None
project_dir: str | None
total_messages: int
user_messages: int
assistant_messages: int
tool_uses: int
models_used: set[str]
def parse_jsonl_file(file_path: Path) -> tuple[list[dict], SessionMetadata]:
messages = []
metadata: SessionMetadata = {
"session_id": None,
"start_time": None,
"end_time": None,
"project_dir": None,
"total_messages": 0,
"user_messages": 0,
"assistant_messages": 0,
"tool_uses": 0,
"models_used": set(),
}
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
try:
data = json.loads(line.strip())
messages.append(data)
if metadata["session_id"] is None and "sessionId" in data:
metadata["session_id"] = data["sessionId"]
if "cwd" in data and metadata["project_dir"] is None:
metadata["project_dir"] = data["cwd"]
if "timestamp" in data:
ts = data["timestamp"]
if metadata["start_time"] is None or ts < metadata["start_time"]:
metadata["start_time"] = ts
if metadata["end_time"] is None or ts > metadata["end_time"]:
metadata["end_time"] = ts
if "message" in data and "role" in data["message"]:
role = data["message"]["role"]
if role == "user":
metadata["user_messages"] += 1
elif role == "assistant":
metadata["assistant_messages"] += 1
if "model" in data["message"]:
metadata["models_used"].add(data["message"]["model"])
if "message" in data and "content" in data["message"]:
for content in data["message"]["content"]:
if (
isinstance(content, dict)
and content.get("type") == "tool_use"
):
metadata["tool_uses"] += 1
except json.JSONDecodeError:
continue
metadata["total_messages"] = len(messages)
return messages, metadata
def format_message_markdown(message_data: dict) -> str:
output = []
if "message" not in message_data:
return ""
msg = message_data["message"]
timestamp = message_data.get("timestamp", "")
if timestamp:
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
output.append(f"**[{dt.strftime('%Y-%m-%d %H:%M:%S')}]**")
role = msg.get("role", "unknown")
if role == "user":
output.append("\n### User\n")
elif role == "assistant":
model = msg.get("model", "")
output.append(f"\n### Assistant ({model})\n")
if "content" in msg:
if isinstance(msg["content"], str):
output.append(msg["content"])
elif isinstance(msg["content"], list):
for content in msg["content"]:
if isinstance(content, dict):
content_type = content.get("type")
if content_type == "text":
output.append(content.get("text", ""))
elif content_type == "thinking":
output.append("\n<details>")
output.append(
"<summary>Internal Reasoning (click to expand)</summary>\n"
)
output.append("```")
output.append(content.get("thinking", ""))
output.append("```")
output.append("</details>\n")
elif content_type == "tool_use":
tool_name = content.get("name", "unknown")
tool_id = content.get("id", "")
output.append(f"\n**Tool Use: {tool_name}** (ID: {tool_id})")
output.append("```json")
output.append(json.dumps(content.get("input", {}), indent=2))
output.append("```\n")
elif content_type == "tool_result":
output.append("\n**Tool Result:**")
output.append("```")
result = content.get("content", "")
if isinstance(result, str):
output.append(result[:5000])
if len(result) > 5000:
output.append(
f"\n... (truncated, {len(result) - 5000} chars omitted)"
)
else:
output.append(str(result))
output.append("```\n")
return "\n".join(output)
def format_message_xml(message_data: dict, parent_element: ET.Element) -> None:
msg_elem = ET.SubElement(parent_element, "message")
msg_elem.set("uuid", message_data.get("uuid", ""))
if message_data.get("parentUuid"):
msg_elem.set("parent-uuid", message_data["parentUuid"])
msg_elem.set("timestamp", message_data.get("timestamp", ""))
if "type" in message_data:
ET.SubElement(msg_elem, "event-type").text = message_data["type"]
if "cwd" in message_data:
ET.SubElement(msg_elem, "working-directory").text = message_data["cwd"]
if "requestId" in message_data:
ET.SubElement(msg_elem, "request-id").text = message_data["requestId"]
if "message" in message_data:
msg = message_data["message"]
if "role" in msg:
ET.SubElement(msg_elem, "role").text = msg["role"]
if "model" in msg:
ET.SubElement(msg_elem, "model").text = msg["model"]
if "content" in msg:
content_elem = ET.SubElement(msg_elem, "content")
if isinstance(msg["content"], str):
content_elem.text = msg["content"]
elif isinstance(msg["content"], list):
for content in msg["content"]:
if isinstance(content, dict):
content_type = content.get("type")
if content_type == "text":
text_elem = ET.SubElement(content_elem, "text")
text_elem.text = clean_text_for_xml(content.get("text", ""))
elif content_type == "thinking":
thinking_elem = ET.SubElement(content_elem, "thinking")
if "signature" in content:
thinking_elem.set("signature", content["signature"])
thinking_elem.text = clean_text_for_xml(
content.get("thinking", "")
)
elif content_type == "tool_use":
tool_elem = ET.SubElement(content_elem, "tool-use")
tool_elem.set("id", content.get("id", ""))
tool_elem.set("name", content.get("name", ""))
input_elem = ET.SubElement(tool_elem, "input")
input_elem.text = clean_text_for_xml(
json.dumps(content.get("input", {}), indent=2)
)
elif content_type == "tool_result":
result_elem = ET.SubElement(content_elem, "tool-result")
if "tool_use_id" in content:
result_elem.set("tool-use-id", content["tool_use_id"])
result_content = content.get("content", "")
if isinstance(result_content, str):
result_elem.text = clean_text_for_xml(result_content)
else:
result_elem.text = clean_text_for_xml(
str(result_content)
)
if "usage" in msg:
usage_elem = ET.SubElement(msg_elem, "usage")
usage = msg["usage"]
if "input_tokens" in usage:
ET.SubElement(usage_elem, "input-tokens").text = str(
usage["input_tokens"]
)
if "output_tokens" in usage:
ET.SubElement(usage_elem, "output-tokens").text = str(
usage["output_tokens"]
)
if "cache_creation_input_tokens" in usage:
ET.SubElement(usage_elem, "cache-creation-tokens").text = str(
usage["cache_creation_input_tokens"]
)
if "cache_read_input_tokens" in usage:
ET.SubElement(usage_elem, "cache-read-tokens").text = str(
usage["cache_read_input_tokens"]
)
if "service_tier" in usage:
ET.SubElement(usage_elem, "service-tier").text = usage["service_tier"]
if "toolUseResult" in message_data:
tool_result = message_data["toolUseResult"]
if isinstance(tool_result, dict):
tool_meta = ET.SubElement(msg_elem, "tool-execution-metadata")
if "bytes" in tool_result:
ET.SubElement(tool_meta, "response-bytes").text = str(
tool_result["bytes"]
)
if "code" in tool_result:
ET.SubElement(tool_meta, "response-code").text = str(
tool_result["code"]
)
if "codeText" in tool_result:
ET.SubElement(tool_meta, "response-text").text = tool_result["codeText"]
if "durationMs" in tool_result:
ET.SubElement(tool_meta, "duration-ms").text = str(
tool_result["durationMs"]
)
if "url" in tool_result:
ET.SubElement(tool_meta, "url").text = tool_result["url"]
def prettify_xml(elem: ET.Element) -> str:
try:
rough_string = ET.tostring(elem, encoding="unicode", method="xml")
reparsed = minidom.parseString(rough_string)
return reparsed.toprettyxml(indent=" ")
except Exception:
return ET.tostring(elem, encoding="unicode", method="xml")
# ---------------------------------------------------------------------------
# Session data collection
# ---------------------------------------------------------------------------
def collect_agent_sessions(
project_path: str, session_id: str, messages: list[dict]
) -> dict[str, Path]:
agents: dict[str, Path] = {}
agent_ids = set()
for msg in messages:
if "agentId" in msg:
agent_id = msg["agentId"]
if agent_id and len(agent_id) == 7:
agent_ids.add(agent_id)
normalized_dir = get_normalized_project_dir(project_path)
claude_project_dir = Path.home() / ".claude" / "projects" / normalized_dir
if not claude_project_dir.exists():
return agents
# Pattern 1: agent-*.jsonl at project root (older format)
for agent_file in claude_project_dir.glob("agent-*.jsonl"):
agent_id = agent_file.stem.removeprefix("agent-")
if agent_id in agent_ids:
try:
with open(agent_file, "r", encoding="utf-8") as f:
first_line = f.readline()
if first_line:
data = json.loads(first_line)
if data.get("sessionId") == session_id:
agents[agent_id] = agent_file
except Exception:
pass
# Pattern 2: <session-id>/subagents/agent-*.jsonl
subagent_dir = claude_project_dir / session_id / "subagents"
if subagent_dir.exists():
for agent_file in subagent_dir.glob("agent-*.jsonl"):
agent_id = agent_file.stem.removeprefix("agent-")
agents[agent_id] = agent_file
return agents
def collect_file_history(session_id: str) -> list[Path]:
file_history_dir = Path.home() / ".claude" / "file-history" / session_id
if not file_history_dir.exists():
return []
return [f for f in file_history_dir.iterdir() if f.is_file()]
def collect_plan_file(slug: str | None) -> Path | None:
if not slug:
return None
plan_file = Path.home() / ".claude" / "plans" / f"{slug}.md"
return plan_file if plan_file.exists() else None
def collect_todos(session_id: str) -> list[Path]:
todos_dir = Path.home() / ".claude" / "todos"
if not todos_dir.exists():
return []
return list(todos_dir.glob(f"{session_id}-*.json"))
def collect_session_env(session_id: str) -> Path | None:
session_env_dir = Path.home() / ".claude" / "session-env" / session_id
if session_env_dir.exists() and any(session_env_dir.iterdir()):
return session_env_dir
return None
class ProjectConfig(TypedDict):
commands: list[Path]
skills: list[Path]
hooks: list[Path]
agents: list[Path]
rules: list[Path]
settings: Path | None
claude_md: Path | None
def collect_project_config(project_path: str) -> ProjectConfig:
project_path_obj = Path(project_path)
config: ProjectConfig = {
"commands": [],
"skills": [],
"hooks": [],
"agents": [],
"rules": [],
"settings": None,
"claude_md": None,
}
claude_dir = project_path_obj / ".claude"
for commands_dir in [claude_dir / "commands", project_path_obj / "commands"]:
if commands_dir.exists():
config["commands"].extend(commands_dir.glob("*.md"))
for key, subdir, pattern in [
("skills", "skills", "*.md"),
("agents", "agents", "*.md"),
("rules", "rules", "*.md"),
]:
d = claude_dir / subdir
if d.exists():
config[key].extend(d.glob(pattern))
hooks_dir = claude_dir / "hooks"
if hooks_dir.exists():
config["hooks"] = [f for f in hooks_dir.iterdir() if f.is_file()]
settings_file = claude_dir / "settings.json"
if settings_file.exists():
config["settings"] = settings_file
claude_md = project_path_obj / "CLAUDE.md"
if claude_md.exists():
config["claude_md"] = claude_md
return config
# ---------------------------------------------------------------------------
# Manifest
# ---------------------------------------------------------------------------
class ManifestOriginalContext(TypedDict):
user: str | None
platform: str
repo_path: str
git_branch: str | None
class ManifestSessionData(TypedDict):
main_session: str
agent_sessions: list[str]
file_history: list[str]
plan_file: str | None
todos: str | None
session_env: str | None
class ManifestConfigSnapshot(TypedDict):
commands: list[str]
skills: list[str]
hooks: list[str]
agents: list[str]
rules: list[str]
settings: str | None
claude_md: str | None
class ManifestStatistics(TypedDict):
message_count: int
user_messages: int
assistant_messages: int
tool_uses: int
duration_seconds: int | None
models_used: list[str]
class Manifest(TypedDict):
cctrace_version: str
export_timestamp: str
session_id: str
session_slug: str | None
export_name: str
claude_code_version: str | None
original_context: ManifestOriginalContext
session_data: ManifestSessionData
config_snapshot: ManifestConfigSnapshot
statistics: ManifestStatistics
anonymized: bool
def generate_manifest(
session_id: str,
slug: str | None,
export_name: str,
metadata: SessionMetadata,
messages: list[dict],
session_files: dict,
config_files: ProjectConfig,
project_path: str,
anonymized: bool = False,
) -> Manifest:
claude_code_version = None
for msg in messages:
if "version" in msg:
claude_code_version = msg["version"]
break
git_branch = None
for msg in messages:
if "gitBranch" in msg:
git_branch = msg["gitBranch"]
break
manifest: Manifest = {
"cctrace_version": CCTRACE_VERSION,
"export_timestamp": datetime.now().isoformat() + "Z",
"session_id": session_id,
"session_slug": slug,
"export_name": export_name,
"claude_code_version": claude_code_version,
"original_context": {
"user": getpass.getuser() if not anonymized else None,
"platform": sys.platform,
"repo_path": str(project_path),
"git_branch": git_branch,
},
"session_data": {
"main_session": "session/main.jsonl",
"agent_sessions": [
f"session/agents/{Path(f).name}"
for f in session_files.get("agents", {}).values()
],
"file_history": [
f"session/file-history/{Path(f).name}"
for f in session_files.get("file_history", [])
],
"plan_file": "session/plan.md" if session_files.get("plan") else None,
"todos": "session/todos.json" if session_files.get("todos") else None,
"session_env": "session/session-env/"
if session_files.get("session_env")
else None,
},
"config_snapshot": {
"commands": [
f"config/commands/{Path(f).name}"
for f in config_files.get("commands", [])
],
"skills": [
f"config/skills/{Path(f).name}" for f in config_files.get("skills", [])
],
"hooks": [
f"config/hooks/{Path(f).name}" for f in config_files.get("hooks", [])
],
"agents": [
f"config/agents/{Path(f).name}" for f in config_files.get("agents", [])
],
"rules": [
f"config/rules/{Path(f).name}" for f in config_files.get("rules", [])
],
"settings": "config/settings.json"
if config_files.get("settings")
else None,
"claude_md": "config/CLAUDE.md" if config_files.get("claude_md") else None,
},
"statistics": {
"message_count": metadata["total_messages"],
"user_messages": metadata["user_messages"],
"assistant_messages": metadata["assistant_messages"],
"tool_uses": metadata["tool_uses"],
"duration_seconds": None,
"models_used": sorted(metadata["models_used"]),
},
"anonymized": anonymized,
}
st, et = metadata.get("start_time"), metadata.get("end_time")
if st is not None and et is not None:
try:
start = datetime.fromisoformat(st.replace("Z", "+00:00"))
end = datetime.fromisoformat(et.replace("Z", "+00:00"))
manifest["statistics"]["duration_seconds"] = int(
(end - start).total_seconds()
)
except Exception:
pass
return manifest
# ---------------------------------------------------------------------------
# Rendered markdown
# ---------------------------------------------------------------------------
def generate_rendered_markdown(
messages: list[dict], metadata: SessionMetadata, manifest: Manifest
) -> str:
lines = []
lines.append(f"# Claude Code Session: {manifest['export_name']}")
lines.append("")
lines.append(f"> Exported from cctrace v{CCTRACE_VERSION}")
lines.append("")
lines.append("## Session Info")
lines.append("")
lines.append("| Field | Value |")
lines.append("|-------|-------|")
lines.append(f"| Session ID | `{manifest['session_id']}` |")
if manifest["session_slug"]:
lines.append(f"| Session Name | {manifest['session_slug']} |")
lines.append(f"| Project | `{manifest['original_context']['repo_path']}` |")
if manifest["original_context"]["git_branch"]:
lines.append(f"| Git Branch | `{manifest['original_context']['git_branch']}` |")
lines.append(f"| Claude Code | v{manifest['claude_code_version']} |")
lines.append(f"| Messages | {manifest['statistics']['message_count']} |")
lines.append(f"| Tool Uses | {manifest['statistics']['tool_uses']} |")
if manifest["statistics"]["duration_seconds"]:
duration = manifest["statistics"]["duration_seconds"]
if duration > 3600:
duration_str = f"{duration // 3600}h {(duration % 3600) // 60}m"
elif duration > 60:
duration_str = f"{duration // 60}m {duration % 60}s"
else:
duration_str = f"{duration}s"
lines.append(f"| Duration | {duration_str} |")
lines.append(f"| Models | {', '.join(manifest['statistics']['models_used'])} |")
lines.append("")
lines.append("## Session Data")
lines.append("")
lines.append("| Component | Status |")
lines.append("|-----------|--------|")
lines.append("| Main Session | `session/main.jsonl` |")
agent_count = len(manifest["session_data"]["agent_sessions"])
lines.append(
f"| Agent Sessions | {str(agent_count) + ' files' if agent_count else 'None'} |"
)
fh_count = len(manifest["session_data"]["file_history"])
lines.append(
f"| File History | {str(fh_count) + ' snapshots' if fh_count else 'None'} |"
)
lines.append(
f"| Plan File | {'Included' if manifest['session_data']['plan_file'] else 'None'} |"
)
lines.append(
f"| Todos | {'Included' if manifest['session_data']['todos'] else 'None'} |"
)
lines.append("")
lines.append("## Project Config")
lines.append("")
lines.append("| Component | Status |")
lines.append("|-----------|--------|")
for label, key in [
("Commands", "commands"),
("Skills", "skills"),
("Hooks", "hooks"),
("Agents", "agents"),
("Rules", "rules"),
]:
count = len(manifest["config_snapshot"][key])
lines.append(f"| {label} | {str(count) + ' files' if count else 'None'} |")
lines.append(
f"| Settings | {'Included' if manifest['config_snapshot']['settings'] else 'None'} |"
)
lines.append(
f"| CLAUDE.md | {'Included' if manifest['config_snapshot']['claude_md'] else 'None'} |"
)
lines.append("")
lines.append("---")
lines.append("")
lines.append("## Conversation")
lines.append("")
for msg in messages:
formatted = format_message_markdown(msg)
if formatted:
lines.append(formatted)
lines.append("")
lines.append("---")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# Conversation file writers
# ---------------------------------------------------------------------------
def write_conversation_md(
export_dir: Path,
messages: list[dict],
metadata: SessionMetadata,
) -> None:
md_path = export_dir / "conversation.md"
with open(md_path, "w", encoding="utf-8") as f:
f.write("# Claude Code Session Export\n\n")
f.write(f"**Session ID:** `{metadata['session_id']}`\n")
f.write(f"**Project:** `{metadata['project_dir']}`\n")
f.write(f"**Start Time:** {metadata['start_time']}\n")
f.write(f"**End Time:** {metadata['end_time']}\n")
f.write(f"**Total Messages:** {metadata['total_messages']}\n")
f.write(f"**User Messages:** {metadata['user_messages']}\n")
f.write(f"**Assistant Messages:** {metadata['assistant_messages']}\n")
f.write(f"**Tool Uses:** {metadata['tool_uses']}\n")
f.write(f"**Models Used:** {', '.join(sorted(metadata['models_used']))}\n\n")
f.write("---\n\n")
for msg in messages:
formatted = format_message_markdown(msg)
if formatted:
f.write(formatted)
f.write("\n\n---\n\n")
def write_conversation_xml(
export_dir: Path,
messages: list[dict],
metadata: SessionMetadata,
) -> None:
root = ET.Element("claude-session")
root.set("xmlns", "https://claude.ai/session-export/v1")
root.set("export-version", "1.0")
meta_elem = ET.SubElement(root, "metadata")
ET.SubElement(meta_elem, "session-id").text = metadata["session_id"]
ET.SubElement(meta_elem, "version").text = (
messages[0].get("version", "") if messages else ""
)
ET.SubElement(meta_elem, "working-directory").text = metadata["project_dir"]
ET.SubElement(meta_elem, "start-time").text = metadata["start_time"]
ET.SubElement(meta_elem, "end-time").text = metadata["end_time"]
ET.SubElement(meta_elem, "export-time").text = datetime.now().isoformat()
stats_elem = ET.SubElement(meta_elem, "statistics")
ET.SubElement(stats_elem, "total-messages").text = str(metadata["total_messages"])
ET.SubElement(stats_elem, "user-messages").text = str(metadata["user_messages"])
ET.SubElement(stats_elem, "assistant-messages").text = str(
metadata["assistant_messages"]
)
ET.SubElement(stats_elem, "tool-uses").text = str(metadata["tool_uses"])
models_elem = ET.SubElement(stats_elem, "models-used")
for model in sorted(metadata["models_used"]):
ET.SubElement(models_elem, "model").text = model
messages_elem = ET.SubElement(root, "messages")
for msg in messages:
format_message_xml(msg, messages_elem)
xml_path = export_dir / "conversation.xml"
xml_string = prettify_xml(root)
with open(xml_path, "w", encoding="utf-8") as f:
f.write(xml_string)
# ---------------------------------------------------------------------------
# JSON serialization helper
# ---------------------------------------------------------------------------
def pre_serialize(data):
if isinstance(data, dict):
return {k: pre_serialize(v) for k, v in data.items()}
if isinstance(data, set):
return sorted(data)
if isinstance(data, list):
return [pre_serialize(item) for item in data]
return data
# ---------------------------------------------------------------------------
# Config snapshot writer
# ---------------------------------------------------------------------------
def _copy_files_to_dir(files: list[Path], dest_dir: Path) -> None:
if not files:
return
dest_dir.mkdir(parents=True, exist_ok=True)
for f in files:
shutil.copy2(f, dest_dir / f.name)
def write_config_snapshot(export_dir: Path, config_files: ProjectConfig) -> None:
config_dir = export_dir / "config"
_copy_files_to_dir(config_files["commands"], config_dir / "commands")
_copy_files_to_dir(config_files["skills"], config_dir / "skills")
_copy_files_to_dir(config_files["hooks"], config_dir / "hooks")
_copy_files_to_dir(config_files["agents"], config_dir / "agents")
_copy_files_to_dir(config_files["rules"], config_dir / "rules")
if config_files["settings"]:
config_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(config_files["settings"], config_dir / "settings.json")
if config_files["claude_md"]:
config_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(config_files["claude_md"], config_dir / "CLAUDE.md")
# ---------------------------------------------------------------------------
# Export
# ---------------------------------------------------------------------------
def export_session(
session_info: dict,
project_path: str,
export_name: str,
output_dir: Path | None = None,
output_format: str = "all",
anonymized: bool = False,
) -> tuple[Path, Manifest]:
project_path_obj = Path(project_path)
messages, metadata = parse_jsonl_file(session_info["path"])
session_id = (
metadata["session_id"] if metadata["session_id"] else session_info["session_id"]
)
slug = None
for msg in messages:
if "slug" in msg:
slug = msg["slug"]
break
if output_dir:
export_dir = output_dir / export_name
else:
export_dir = project_path_obj / ".claude-sessions" / export_name
export_dir.mkdir(parents=True, exist_ok=True)
# Collect session data
agent_sessions = collect_agent_sessions(project_path, session_id, messages)
file_history = collect_file_history(session_id)
plan_file = collect_plan_file(slug)
todos = collect_todos(session_id)
session_env = collect_session_env(session_id)
session_files = {
"agents": agent_sessions,
"file_history": file_history,
"plan": plan_file,
"todos": todos,
"session_env": session_env,
}
# Collect project config
config_files = collect_project_config(project_path)
# Generate manifest
manifest = generate_manifest(
session_id,
slug,
export_name,
metadata,
messages,
session_files,
config_files,
project_path,
anonymized,
)
# -- Write session data --
session_dir = export_dir / "session"
session_dir.mkdir(exist_ok=True)
shutil.copy2(session_info["path"], session_dir / "main.jsonl")
if agent_sessions:
agents_dir = session_dir / "agents"
agents_dir.mkdir(exist_ok=True)
for agent_id, agent_path in agent_sessions.items():
shutil.copy2(agent_path, agents_dir / f"agent-{agent_id}.jsonl")
if file_history:
fh_dir = session_dir / "file-history"
fh_dir.mkdir(exist_ok=True)
for fh_file in file_history:
shutil.copy2(fh_file, fh_dir / fh_file.name)
if plan_file:
shutil.copy2(plan_file, session_dir / "plan.md")
if todos:
all_todos = []
for todo_file in todos:
try:
with open(todo_file, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, list):
all_todos.extend(data)
else:
all_todos.append(data)
except Exception:
pass
with open(session_dir / "todos.json", "w", encoding="utf-8") as f:
json.dump(all_todos, f, indent=2)
if session_env:
env_dir = session_dir / "session-env"
env_dir.mkdir(exist_ok=True)
for env_file in session_env.iterdir():
if env_file.is_file():
shutil.copy2(env_file, env_dir / env_file.name)
# -- Write config snapshot --
write_config_snapshot(export_dir, config_files)
# -- Write conversation files --
if output_format in ("md", "all"):
write_conversation_md(export_dir, messages, metadata)
if output_format in ("xml", "all"):
write_conversation_xml(export_dir, messages, metadata)
# -- Write manifest --
manifest_path = export_dir / ".cctrace-manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(pre_serialize(manifest), f, indent=2)
# -- Write RENDERED.md --
rendered_md = generate_rendered_markdown(messages, metadata, manifest)
with open(export_dir / "RENDERED.md", "w", encoding="utf-8") as f:
f.write(rendered_md)
return export_dir, manifest
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def find_session_by_prefix(sessions: list[dict], prefix: str) -> dict | None:
for session in sessions:
if session["session_id"] == prefix or session["session_id"].startswith(prefix):
return session
return None
def print_export_summary(export_path: Path, manifest: Manifest) -> None:
sid = manifest["session_id"]
slug = manifest.get("session_slug")
label = f"{sid[:8]}..."
if slug:
label += f" ({slug})"
stats = manifest["statistics"]
agents = len(manifest["session_data"]["agent_sessions"])
fh = len(manifest["session_data"]["file_history"])
print(
f" {label}: {stats['message_count']} msgs, "
f"{stats['tool_uses']} tools, "
f"{agents} agents, "
f"{fh} file-history snapshots"
)
def main() -> int:
parser = argparse.ArgumentParser(
description="Export Claude Code sessions to structured directories"
)
parser.add_argument(
"--session",
dest="session_id",
help="Export a specific session ID (supports prefix match)",
)
parser.add_argument(
"--latest",
type=int,
metavar="N",
help="Export only the N most recent sessions",
)
parser.add_argument(
"--output-dir",
help="Custom output directory (default: .claude-sessions/ in project root)",
)
parser.add_argument(
"--format",
choices=["md", "xml", "all"],
default="all",
help="Output format (default: all)",
)
parser.add_argument(
"--export-name",
help="Custom name for the export directory (only with --session)",
)
parser.add_argument(
"--anonymize",
action="store_true",
help="Exclude user/machine info from export",
)
args = parser.parse_args()
cwd = os.getcwd()
sessions = find_project_sessions(cwd)
if not sessions:
print(
"No Claude Code sessions found for this project.\n"
"Make sure you're running this from a project directory "
"with Claude Code session history."
)
return 1
print(f"Found {len(sessions)} session(s) for this project")
# Determine which sessions to export
if args.session_id:
match = find_session_by_prefix(sessions, args.session_id)
if not match:
print(f"Session {args.session_id} not found.")
print("\nAvailable sessions:")
for s in sessions[:10]:
print(f" {s['session_id']}")
return 1
sessions_to_export = [match]
elif args.latest:
sessions_to_export = sessions[: args.latest]
else:
sessions_to_export = sessions
output_dir = Path(args.output_dir) if args.output_dir else None
print(f"Exporting {len(sessions_to_export)} session(s)...\n")
exported = 0
failed = 0
for session_info in sessions_to_export:
export_name = (
args.export_name
if args.export_name and len(sessions_to_export) == 1
else session_info["session_id"]
)
try:
export_path, manifest = export_session(
session_info,
cwd,
export_name,
output_dir=output_dir,
output_format=args.format,
anonymized=args.anonymize,
)
print_export_summary(export_path, manifest)
exported += 1
except Exception as e:
print(f" {session_info['session_id'][:8]}...: FAILED ({e})")
failed += 1
out_location = output_dir or Path(cwd) / ".claude-sessions"
print(f"\nExported {exported} session(s) to {out_location}")
if failed:
print(f"{failed} session(s) failed")
return 1 if failed and not exported else 0
if __name__ == "__main__":
sys.exit(main())
# vim: syn=python ft=python
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment