Загрузить файлы в «/»
This commit is contained in:
578
claude_chat_to_md.py
Normal file
578
claude_chat_to_md.py
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Convert Claude Code chat sessions (.jsonl) to Markdown.
|
||||||
|
|
||||||
|
Reads session JSONL files from ~/.claude/projects/ and produces clean,
|
||||||
|
readable Markdown — including subagent conversations.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# List all sessions
|
||||||
|
claude-chat-to-md --list
|
||||||
|
|
||||||
|
# Convert a specific session (by UUID or partial match)
|
||||||
|
claude-chat-to-md 2354ca15
|
||||||
|
|
||||||
|
# Convert the most recent session for a project
|
||||||
|
claude-chat-to-md --latest --project myapp
|
||||||
|
|
||||||
|
# Convert all sessions
|
||||||
|
claude-chat-to-md --all
|
||||||
|
|
||||||
|
# Output to a specific file
|
||||||
|
claude-chat-to-md 2354ca15 -o chat.md
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Allow overriding via environment variable or use current dir if it looks like .claude
|
||||||
|
if Path("projects").is_dir() and Path("sessions").is_dir():
|
||||||
|
CLAUDE_DIR = Path.cwd()
|
||||||
|
else:
|
||||||
|
CLAUDE_DIR = Path.home() / ".claude"
|
||||||
|
PROJECTS_DIR = CLAUDE_DIR / "projects"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SessionInfo:
|
||||||
|
"""Metadata about a discovered session."""
|
||||||
|
|
||||||
|
path: Path
|
||||||
|
session_id: str
|
||||||
|
project_path: str
|
||||||
|
title: str | None = None
|
||||||
|
timestamp: str | None = None
|
||||||
|
subagent_dir: Path | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display_project(self) -> str:
|
||||||
|
"""Human-readable project path.
|
||||||
|
|
||||||
|
Claude Code encodes project paths by replacing '/' with '-',
|
||||||
|
e.g. '/Users/nick/my-project' becomes '-Users-nick-my-project'.
|
||||||
|
We reverse this by splitting on the known leading pattern and
|
||||||
|
reconstructing path separators only where they originally were.
|
||||||
|
"""
|
||||||
|
# The encoded path starts with '-' (representing the leading '/')
|
||||||
|
# and uses '-' for every '/' in the original path. However, actual
|
||||||
|
# directory names may contain hyphens too. We can't perfectly reverse
|
||||||
|
# this, but we can use os.sep knowledge: the original path segments
|
||||||
|
# are valid directory names. We try to find the longest valid prefix.
|
||||||
|
raw = self.project_path
|
||||||
|
if not raw:
|
||||||
|
return raw
|
||||||
|
|
||||||
|
# Try to find the actual path on disk by checking ~/.claude/projects/
|
||||||
|
# Fall back to a heuristic: replace leading '-' with '/' and try to
|
||||||
|
# reconstruct by checking which splits produce real-looking paths.
|
||||||
|
# Best heuristic: the encoded form is the absolute path with '/' -> '-'
|
||||||
|
# and a leading '-'. Try to resolve by checking if the path exists.
|
||||||
|
candidate = "/" + raw[1:] if raw.startswith("-") else raw
|
||||||
|
# Replace hyphens with '/' and check if it's a real path
|
||||||
|
full_replace = candidate.replace("-", "/")
|
||||||
|
if Path(full_replace).exists():
|
||||||
|
return full_replace.lstrip("/")
|
||||||
|
|
||||||
|
# Fallback: use a smarter reconstruction. Split by '-' and greedily
|
||||||
|
# rejoin segments that form existing directories.
|
||||||
|
parts = raw.lstrip("-").split("-")
|
||||||
|
reconstructed = [parts[0]]
|
||||||
|
for part in parts[1:]:
|
||||||
|
# Try joining with the previous segment (hyphenated name)
|
||||||
|
test_hyphen = "/" + "/".join(reconstructed[:-1] + [reconstructed[-1] + "-" + part])
|
||||||
|
test_slash = "/" + "/".join(reconstructed + [part])
|
||||||
|
if Path(test_hyphen).exists():
|
||||||
|
reconstructed[-1] += "-" + part
|
||||||
|
else:
|
||||||
|
reconstructed.append(part)
|
||||||
|
|
||||||
|
return "/".join(reconstructed)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_sessions() -> list[SessionInfo]:
|
||||||
|
"""Find all session JSONL files under ~/.claude/projects/."""
|
||||||
|
sessions: list[SessionInfo] = []
|
||||||
|
if not PROJECTS_DIR.exists():
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
for project_dir in PROJECTS_DIR.iterdir():
|
||||||
|
if not project_dir.is_dir():
|
||||||
|
continue
|
||||||
|
for jsonl_file in project_dir.glob("*.jsonl"):
|
||||||
|
sid = jsonl_file.stem
|
||||||
|
# Skip subagent files
|
||||||
|
if sid.startswith("agent-"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
info = SessionInfo(
|
||||||
|
path=jsonl_file,
|
||||||
|
session_id=sid,
|
||||||
|
project_path=project_dir.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for subagent directory
|
||||||
|
subagent_dir = project_dir / sid / "subagents"
|
||||||
|
if subagent_dir.is_dir():
|
||||||
|
info.subagent_dir = subagent_dir
|
||||||
|
|
||||||
|
# Quick scan for title and first timestamp
|
||||||
|
try:
|
||||||
|
with open(jsonl_file, encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
obj = json.loads(line)
|
||||||
|
if obj.get("type") == "ai-title":
|
||||||
|
info.title = obj.get("aiTitle")
|
||||||
|
if not info.timestamp and obj.get("timestamp"):
|
||||||
|
info.timestamp = obj["timestamp"]
|
||||||
|
if info.title and info.timestamp:
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sessions.append(info)
|
||||||
|
|
||||||
|
sessions.sort(key=lambda s: s.timestamp or "", reverse=True)
|
||||||
|
return sessions
|
||||||
|
|
||||||
|
|
||||||
|
def parse_messages(jsonl_path: Path) -> list[dict]:
|
||||||
|
"""Parse a JSONL file into an ordered list of message records."""
|
||||||
|
messages = []
|
||||||
|
with open(jsonl_path, encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if obj.get("type") in ("user", "assistant"):
|
||||||
|
messages.append(obj)
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def format_tool_use(content_block: dict) -> str:
|
||||||
|
"""Format a tool_use content block as Markdown."""
|
||||||
|
name = content_block.get("name", "Unknown")
|
||||||
|
inp = content_block.get("input", {})
|
||||||
|
|
||||||
|
lines = [f"**Tool: {name}**"]
|
||||||
|
|
||||||
|
if name == "Bash":
|
||||||
|
cmd = inp.get("command", "")
|
||||||
|
desc = inp.get("description", "")
|
||||||
|
if desc:
|
||||||
|
lines.append(f"*{desc}*")
|
||||||
|
lines.append(f"```bash\n{cmd}\n```")
|
||||||
|
elif name == "Read":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
lines.append(f"Reading `{fp}`")
|
||||||
|
elif name == "Write":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
content = inp.get("content", "")
|
||||||
|
lines.append(f"Writing `{fp}`")
|
||||||
|
if content:
|
||||||
|
# Show first/last lines for long files
|
||||||
|
content_lines = content.split("\n")
|
||||||
|
if len(content_lines) > 30:
|
||||||
|
preview = "\n".join(content_lines[:15])
|
||||||
|
preview += f"\n\n... ({len(content_lines) - 30} lines omitted) ...\n\n"
|
||||||
|
preview += "\n".join(content_lines[-15:])
|
||||||
|
else:
|
||||||
|
preview = content
|
||||||
|
ext = Path(fp).suffix.lstrip(".")
|
||||||
|
lines.append(f"```{ext}\n{preview}\n```")
|
||||||
|
elif name == "Edit":
|
||||||
|
fp = inp.get("file_path", "")
|
||||||
|
old = inp.get("old_string", "")
|
||||||
|
new = inp.get("new_string", "")
|
||||||
|
lines.append(f"Editing `{fp}`")
|
||||||
|
if old or new:
|
||||||
|
lines.append("```diff")
|
||||||
|
for ol in old.split("\n"):
|
||||||
|
lines.append(f"- {ol}")
|
||||||
|
for nl in new.split("\n"):
|
||||||
|
lines.append(f"+ {nl}")
|
||||||
|
lines.append("```")
|
||||||
|
elif name == "Grep":
|
||||||
|
pattern = inp.get("pattern", "")
|
||||||
|
path = inp.get("path", ".")
|
||||||
|
lines.append(f"Searching for `{pattern}` in `{path}`")
|
||||||
|
elif name == "Glob":
|
||||||
|
pattern = inp.get("pattern", "")
|
||||||
|
lines.append(f"Finding files matching `{pattern}`")
|
||||||
|
elif name == "Agent":
|
||||||
|
desc = inp.get("description", "")
|
||||||
|
subtype = inp.get("subagent_type", "general-purpose")
|
||||||
|
lines.append(f"Spawning **{subtype}** agent: *{desc}*")
|
||||||
|
prompt = inp.get("prompt", "")
|
||||||
|
if prompt:
|
||||||
|
# Truncate long prompts
|
||||||
|
if len(prompt) > 500:
|
||||||
|
prompt = prompt[:500] + "..."
|
||||||
|
lines.append(f"\n> {prompt}")
|
||||||
|
elif name in ("WebSearch", "WebFetch"):
|
||||||
|
query = inp.get("query", inp.get("url", ""))
|
||||||
|
lines.append(f"`{query}`")
|
||||||
|
else:
|
||||||
|
# Generic: show input as JSON
|
||||||
|
if inp:
|
||||||
|
lines.append(f"```json\n{json.dumps(inp, indent=2)[:500]}\n```")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tool_result(content_block: dict) -> str:
|
||||||
|
"""Format a tool_result content block as Markdown."""
|
||||||
|
content = content_block.get("content", "")
|
||||||
|
is_error = content_block.get("is_error", False)
|
||||||
|
|
||||||
|
if isinstance(content, list):
|
||||||
|
# Content can be a list of text/image blocks
|
||||||
|
parts = []
|
||||||
|
for item in content:
|
||||||
|
if isinstance(item, dict) and item.get("type") == "text":
|
||||||
|
parts.append(item["text"])
|
||||||
|
elif isinstance(item, dict) and item.get("type") == "image":
|
||||||
|
parts.append("*[image]*")
|
||||||
|
else:
|
||||||
|
parts.append(str(item))
|
||||||
|
content = "\n".join(parts)
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
prefix = "**Error:**\n" if is_error else ""
|
||||||
|
# Truncate very long results
|
||||||
|
lines = content.split("\n")
|
||||||
|
if len(lines) > 50:
|
||||||
|
content = "\n".join(lines[:25])
|
||||||
|
content += f"\n\n... ({len(lines) - 50} lines omitted) ...\n\n"
|
||||||
|
content += "\n".join(lines[-25:])
|
||||||
|
|
||||||
|
return f"{prefix}```\n{content}\n```"
|
||||||
|
|
||||||
|
|
||||||
|
def format_content(content: list | str) -> str:
|
||||||
|
"""Format a message's content array into Markdown."""
|
||||||
|
if isinstance(content, str):
|
||||||
|
return content
|
||||||
|
|
||||||
|
parts = []
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, str):
|
||||||
|
parts.append(block)
|
||||||
|
elif not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
elif block.get("type") == "text":
|
||||||
|
text = block["text"]
|
||||||
|
# Strip IDE system tags for cleaner output
|
||||||
|
text = re.sub(r"<ide_opened_file>.*?</ide_opened_file>", "", text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL)
|
||||||
|
text = text.strip()
|
||||||
|
if text:
|
||||||
|
parts.append(text)
|
||||||
|
elif block.get("type") == "tool_use":
|
||||||
|
parts.append(format_tool_use(block))
|
||||||
|
elif block.get("type") == "tool_result":
|
||||||
|
parts.append(format_tool_result(block))
|
||||||
|
|
||||||
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_subagent(jsonl_path: Path, meta_path: Path | None) -> str:
|
||||||
|
"""Convert a subagent session into a Markdown section."""
|
||||||
|
meta = {}
|
||||||
|
if meta_path and meta_path.exists():
|
||||||
|
try:
|
||||||
|
meta = json.loads(meta_path.read_text(encoding='utf-8'))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
agent_type = meta.get("agentType", "unknown")
|
||||||
|
description = meta.get("description", "Subagent")
|
||||||
|
|
||||||
|
messages = parse_messages(jsonl_path)
|
||||||
|
lines = [f"#### Subagent: {description}", f"*Type: {agent_type}*\n"]
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("type", msg.get("message", {}).get("role", "unknown"))
|
||||||
|
content = msg.get("message", {}).get("content", [])
|
||||||
|
formatted = format_content(content)
|
||||||
|
if not formatted:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
lines.append(f"**Prompt:**\n\n{formatted}\n")
|
||||||
|
elif role == "assistant":
|
||||||
|
lines.append(f"{formatted}\n")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def convert_session(
|
||||||
|
session: SessionInfo,
|
||||||
|
include_subagents: bool = True,
|
||||||
|
include_tool_results: bool = True,
|
||||||
|
) -> str:
|
||||||
|
"""Convert a full session to Markdown."""
|
||||||
|
messages = parse_messages(session.path)
|
||||||
|
|
||||||
|
# Collect subagent data if available
|
||||||
|
subagents: dict[str, str] = {} # tool_use_id -> markdown
|
||||||
|
if include_subagents and session.subagent_dir:
|
||||||
|
for meta_file in session.subagent_dir.glob("*.meta.json"):
|
||||||
|
agent_id = meta_file.stem.replace(".meta", "")
|
||||||
|
jsonl_file = session.subagent_dir / f"{agent_id}.jsonl"
|
||||||
|
if jsonl_file.exists():
|
||||||
|
subagents[agent_id] = convert_subagent(jsonl_file, meta_file)
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Header
|
||||||
|
title = session.title or "Untitled Session"
|
||||||
|
ts = session.timestamp or ""
|
||||||
|
if ts:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||||
|
ts = dt.strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
lines.append(f"# {title}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"**Session:** `{session.session_id}` ")
|
||||||
|
lines.append(f"**Project:** `{session.display_project}` ")
|
||||||
|
if ts:
|
||||||
|
lines.append(f"**Date:** {ts} ")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("---")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Track which agent tool_use_ids map to which subagent
|
||||||
|
agent_tool_use_map: dict[str, str] = {}
|
||||||
|
|
||||||
|
for msg in messages:
|
||||||
|
role = msg.get("type", "")
|
||||||
|
content = msg.get("message", {}).get("content", [])
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
# Check if this is a tool_result (not direct user input)
|
||||||
|
if isinstance(content, list) and all(
|
||||||
|
isinstance(b, dict) and b.get("type") == "tool_result"
|
||||||
|
for b in content
|
||||||
|
):
|
||||||
|
if include_tool_results:
|
||||||
|
formatted = format_content(content)
|
||||||
|
if formatted.strip():
|
||||||
|
lines.append(f"<details><summary>Tool Result</summary>\n\n{formatted}\n\n</details>\n")
|
||||||
|
continue
|
||||||
|
|
||||||
|
formatted = format_content(content)
|
||||||
|
if formatted.strip():
|
||||||
|
lines.append(f"## User\n\n{formatted}\n")
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
formatted_parts = []
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if isinstance(block, dict) and block.get("type") == "tool_use":
|
||||||
|
if block.get("name") == "Agent":
|
||||||
|
# Map this tool_use to subagent output
|
||||||
|
desc = block.get("input", {}).get("description", "")
|
||||||
|
# Find matching subagent by description
|
||||||
|
for aid, md in subagents.items():
|
||||||
|
if desc and desc in md:
|
||||||
|
agent_tool_use_map[block["id"]] = aid
|
||||||
|
|
||||||
|
formatted_parts.append(format_tool_use(block))
|
||||||
|
elif isinstance(block, dict) and block.get("type") == "text":
|
||||||
|
import re
|
||||||
|
text = block["text"]
|
||||||
|
text = re.sub(r"<ide_opened_file>.*?</ide_opened_file>", "", text, flags=re.DOTALL)
|
||||||
|
text = re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL)
|
||||||
|
text = text.strip()
|
||||||
|
if text:
|
||||||
|
formatted_parts.append(text)
|
||||||
|
elif isinstance(block, dict) and block.get("type") == "tool_result":
|
||||||
|
if include_tool_results:
|
||||||
|
r = format_tool_result(block)
|
||||||
|
if r:
|
||||||
|
formatted_parts.append(r)
|
||||||
|
|
||||||
|
formatted = "\n\n".join(formatted_parts)
|
||||||
|
if formatted.strip():
|
||||||
|
lines.append(f"## Assistant\n\n{formatted}\n")
|
||||||
|
|
||||||
|
# Insert subagent conversations after the assistant message that spawned them
|
||||||
|
if isinstance(content, list):
|
||||||
|
for block in content:
|
||||||
|
if (
|
||||||
|
isinstance(block, dict)
|
||||||
|
and block.get("type") == "tool_use"
|
||||||
|
and block.get("name") == "Agent"
|
||||||
|
):
|
||||||
|
tid = block.get("id", "")
|
||||||
|
if tid in agent_tool_use_map:
|
||||||
|
aid = agent_tool_use_map[tid]
|
||||||
|
lines.append(f"<details><summary>Subagent Conversation</summary>\n")
|
||||||
|
lines.append(subagents[aid])
|
||||||
|
lines.append("</details>\n")
|
||||||
|
else:
|
||||||
|
# Try to find by description match
|
||||||
|
desc = block.get("input", {}).get("description", "")
|
||||||
|
for aid, md in subagents.items():
|
||||||
|
if desc and desc.lower() in md.lower() and aid not in agent_tool_use_map.values():
|
||||||
|
lines.append(f"<details><summary>Subagent Conversation</summary>\n")
|
||||||
|
lines.append(md)
|
||||||
|
lines.append("</details>\n")
|
||||||
|
agent_tool_use_map[tid] = aid
|
||||||
|
break
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sessions(sessions: list[SessionInfo], out: TextIO = sys.stdout) -> None:
|
||||||
|
"""Print a table of discovered sessions."""
|
||||||
|
if not sessions:
|
||||||
|
print("No sessions found.", file=out)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"{'#':<4} {'Date':<20} {'ID':<12} {'Title':<50} {'Project'}", file=out)
|
||||||
|
print("-" * 120, file=out)
|
||||||
|
for i, s in enumerate(sessions, 1):
|
||||||
|
ts = ""
|
||||||
|
if s.timestamp:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00"))
|
||||||
|
ts = dt.strftime("%Y-%m-%d %H:%M")
|
||||||
|
except ValueError:
|
||||||
|
ts = s.timestamp[:16]
|
||||||
|
title = (s.title or "Untitled")[:50]
|
||||||
|
sid = s.session_id[:10] + ".."
|
||||||
|
proj = s.display_project
|
||||||
|
# Shorten project path
|
||||||
|
parts = proj.split("/")
|
||||||
|
if len(parts) > 3:
|
||||||
|
proj = "/".join(parts[-3:])
|
||||||
|
print(f"{i:<4} {ts:<20} {sid:<12} {title:<50} {proj}", file=out)
|
||||||
|
|
||||||
|
|
||||||
|
def find_session(sessions: list[SessionInfo], query: str) -> SessionInfo | None:
|
||||||
|
"""Find a session by UUID prefix or index."""
|
||||||
|
# Try as index
|
||||||
|
try:
|
||||||
|
idx = int(query) - 1
|
||||||
|
if 0 <= idx < len(sessions):
|
||||||
|
return sessions[idx]
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Try as UUID prefix
|
||||||
|
for s in sessions:
|
||||||
|
if s.session_id.startswith(query):
|
||||||
|
return s
|
||||||
|
|
||||||
|
# Try title substring
|
||||||
|
for s in sessions:
|
||||||
|
if s.title and query.lower() in s.title.lower():
|
||||||
|
return s
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Convert Claude Code chat sessions to Markdown",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=__doc__,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"session",
|
||||||
|
nargs="?",
|
||||||
|
help="Session UUID (prefix), index number from --list, or title substring",
|
||||||
|
)
|
||||||
|
parser.add_argument("--list", "-l", action="store_true", help="List all sessions")
|
||||||
|
parser.add_argument("--latest", action="store_true", help="Convert the most recent session")
|
||||||
|
parser.add_argument("--all", action="store_true", help="Convert all sessions")
|
||||||
|
parser.add_argument("--project", "-p", help="Filter by project path substring")
|
||||||
|
parser.add_argument("--output", "-o", help="Output file path (default: stdout)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-subagents", action="store_true", help="Exclude subagent conversations"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-tool-results", action="store_true", help="Exclude tool results"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output-dir", "-d", help="Output directory (for --all mode)"
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
sessions = discover_sessions()
|
||||||
|
|
||||||
|
if args.project:
|
||||||
|
sessions = [s for s in sessions if args.project.lower() in s.project_path.lower()]
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
list_sessions(sessions)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.session and not args.latest and not args.all:
|
||||||
|
parser.print_help()
|
||||||
|
print("\nUse --list to see available sessions.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
include_subagents = not args.no_subagents
|
||||||
|
include_tool_results = not args.no_tool_results
|
||||||
|
|
||||||
|
if args.all:
|
||||||
|
out_dir = Path(args.output_dir) if args.output_dir else Path(".")
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for s in sessions:
|
||||||
|
md = convert_session(s, include_subagents, include_tool_results)
|
||||||
|
ts = ""
|
||||||
|
if s.timestamp:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00"))
|
||||||
|
ts = dt.strftime("%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
safe_title = (s.title or "untitled").replace(" ", "-").replace("/", "-")[:50]
|
||||||
|
filename = f"{ts}-{safe_title}.md" if ts else f"{s.session_id[:8]}-{safe_title}.md"
|
||||||
|
out_path = out_dir / filename
|
||||||
|
out_path.write_text(md, encoding='utf-8')
|
||||||
|
print(f"Wrote {out_path}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.latest:
|
||||||
|
if not sessions:
|
||||||
|
print("No sessions found.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
session = sessions[0]
|
||||||
|
else:
|
||||||
|
session = find_session(sessions, args.session)
|
||||||
|
if not session:
|
||||||
|
print(f"Session not found: {args.session}", file=sys.stderr)
|
||||||
|
print("Use --list to see available sessions.", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
md = convert_session(session, include_subagents, include_tool_results)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(md, encoding='utf-8')
|
||||||
|
print(f"Wrote {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(md)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user