claude-scrollback 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_scrollback/__init__.py +1 -0
- claude_scrollback/__main__.py +248 -0
- claude_scrollback/generator.py +1388 -0
- claude_scrollback/server.py +69 -0
- claude_scrollback-0.1.0.dist-info/METADATA +202 -0
- claude_scrollback-0.1.0.dist-info/RECORD +9 -0
- claude_scrollback-0.1.0.dist-info/WHEEL +5 -0
- claude_scrollback-0.1.0.dist-info/entry_points.txt +2 -0
- claude_scrollback-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
claude-scrollback CLI
|
|
4
|
+
Usage: claude-scrollback <command> [path] [options]
|
|
5
|
+
python -m claude_scrollback <command> [path] [options]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
import sys
|
|
10
|
+
import argparse
|
|
11
|
+
import tempfile
|
|
12
|
+
import webbrowser
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .generator import generate_html, process_directory
|
|
18
|
+
from .server import run as run_server
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ── Path resolution ────────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
def map_project_to_sessions(project_path: Path) -> Path:
|
|
24
|
+
"""
|
|
25
|
+
Map a project directory to its ~/.claude/projects/ entry.
|
|
26
|
+
Claude Code encodes the path by replacing : / \\ with -.
|
|
27
|
+
e.g. C:\\Users\\alex\\Projects\\myapp -> C--Users-alex-Projects-myapp
|
|
28
|
+
"""
|
|
29
|
+
path_str = str(project_path.resolve())
|
|
30
|
+
mapped = path_str.replace("\\", "-").replace("/", "-").replace(":", "-")
|
|
31
|
+
return Path.home() / ".claude" / "projects" / mapped
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def find_sessions_dir(path_str: str) -> Path:
|
|
35
|
+
"""
|
|
36
|
+
Given a path string, return the sessions directory to use.
|
|
37
|
+
|
|
38
|
+
Resolution order:
|
|
39
|
+
1. Path has .jsonl files directly or recursively -> use as-is
|
|
40
|
+
2. Otherwise -> map to ~/.claude/projects/<encoded>/
|
|
41
|
+
"""
|
|
42
|
+
path = Path(path_str).resolve()
|
|
43
|
+
if not path.exists():
|
|
44
|
+
raise FileNotFoundError(f"Path not found: {path}")
|
|
45
|
+
|
|
46
|
+
if any(path.glob("*.jsonl")) or any(path.rglob("*.jsonl")):
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
mapped = map_project_to_sessions(path)
|
|
50
|
+
if mapped.exists() and any(mapped.rglob("*.jsonl")):
|
|
51
|
+
print(f"Using sessions from {mapped}")
|
|
52
|
+
return mapped
|
|
53
|
+
|
|
54
|
+
raise FileNotFoundError(
|
|
55
|
+
f"No Claude Code sessions found.\n"
|
|
56
|
+
f" Checked: {path}\n"
|
|
57
|
+
f" Checked: {mapped}\n"
|
|
58
|
+
f"Run from a Claude Code project directory, or pass the sessions path directly."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def default_sessions_dir() -> Path:
|
|
63
|
+
"""Return ~/.claude/projects/ if it exists and has sessions."""
|
|
64
|
+
default = Path.home() / ".claude" / "projects"
|
|
65
|
+
if default.exists() and any(default.rglob("*.jsonl")):
|
|
66
|
+
return default
|
|
67
|
+
raise FileNotFoundError(
|
|
68
|
+
"~/.claude/projects/ not found or empty.\n"
|
|
69
|
+
"Pass a path: claude-scrollback view <project-or-sessions-dir>"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def resolve(path_arg):
|
|
74
|
+
"""Resolve optional path arg to a sessions directory."""
|
|
75
|
+
if path_arg:
|
|
76
|
+
return find_sessions_dir(path_arg)
|
|
77
|
+
return default_sessions_dir()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def open_browser(url: str, delay: float = 0.4):
|
|
81
|
+
"""Open browser after a short delay (gives server time to start)."""
|
|
82
|
+
def _open():
|
|
83
|
+
time.sleep(delay)
|
|
84
|
+
try:
|
|
85
|
+
webbrowser.open(url)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
threading.Thread(target=_open, daemon=True).start()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── Subcommands ────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
def cmd_view(args):
|
|
94
|
+
# Single file
|
|
95
|
+
if args.path and Path(args.path).is_file():
|
|
96
|
+
src = Path(args.path)
|
|
97
|
+
if src.suffix != ".jsonl":
|
|
98
|
+
print(f"Error: {src} is not a .jsonl file")
|
|
99
|
+
sys.exit(1)
|
|
100
|
+
out = src.with_suffix(".html")
|
|
101
|
+
print(f"Generating {out} ...")
|
|
102
|
+
generate_html(src, out)
|
|
103
|
+
url = out.resolve().as_uri()
|
|
104
|
+
if not args.no_open:
|
|
105
|
+
webbrowser.open(url)
|
|
106
|
+
else:
|
|
107
|
+
print(f"Open: {url}")
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
sessions_dir = resolve(args.path)
|
|
112
|
+
except FileNotFoundError as e:
|
|
113
|
+
print(f"Error: {e}")
|
|
114
|
+
sys.exit(1)
|
|
115
|
+
|
|
116
|
+
url = f"http://localhost:{args.port}"
|
|
117
|
+
if not args.no_open:
|
|
118
|
+
open_browser(url)
|
|
119
|
+
run_server(sessions_dir, args.port)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
UUID_RE = re.compile(
|
|
124
|
+
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}",
|
|
125
|
+
re.IGNORECASE,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def cmd_show(args):
|
|
129
|
+
# Collect UUIDs from positional arg and/or piped stdin
|
|
130
|
+
uuids = []
|
|
131
|
+
if args.uuid:
|
|
132
|
+
if UUID_RE.fullmatch(args.uuid):
|
|
133
|
+
uuids.append(args.uuid.lower())
|
|
134
|
+
else:
|
|
135
|
+
print(f"Error: '{args.uuid}' is not a valid UUID")
|
|
136
|
+
sys.exit(1)
|
|
137
|
+
|
|
138
|
+
if not sys.stdin.isatty():
|
|
139
|
+
text = sys.stdin.read()
|
|
140
|
+
for m in UUID_RE.finditer(text):
|
|
141
|
+
u = m.group(0).lower()
|
|
142
|
+
if u not in uuids:
|
|
143
|
+
uuids.append(u)
|
|
144
|
+
|
|
145
|
+
if not uuids:
|
|
146
|
+
print("Error: provide a UUID argument or pipe text containing UUIDs")
|
|
147
|
+
sys.exit(1)
|
|
148
|
+
|
|
149
|
+
projects = Path.home() / ".claude" / "projects"
|
|
150
|
+
opened = 0
|
|
151
|
+
for uuid in uuids:
|
|
152
|
+
matches = list(projects.rglob(f"{uuid}.jsonl"))
|
|
153
|
+
if not matches:
|
|
154
|
+
print(f"Warning: no session found for {uuid}")
|
|
155
|
+
continue
|
|
156
|
+
for src in matches:
|
|
157
|
+
out = Path(tempfile.mkdtemp()) / (src.stem + ".html")
|
|
158
|
+
generate_html(src, out)
|
|
159
|
+
webbrowser.open(out.resolve().as_uri())
|
|
160
|
+
print(f"Opened: {src.name}")
|
|
161
|
+
opened += 1
|
|
162
|
+
|
|
163
|
+
if opened == 0:
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def cmd_generate(args):
|
|
168
|
+
if args.path and Path(args.path).is_file():
|
|
169
|
+
src = Path(args.path)
|
|
170
|
+
if src.suffix != ".jsonl":
|
|
171
|
+
print(f"Error: {src} is not a .jsonl file")
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
out = src.with_suffix(".html")
|
|
174
|
+
generate_html(src, out)
|
|
175
|
+
print(f"Written to {out}")
|
|
176
|
+
return
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
sessions_dir = resolve(args.path)
|
|
180
|
+
except FileNotFoundError as e:
|
|
181
|
+
print(f"Error: {e}")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
|
|
184
|
+
out_dir = Path(args.out_dir)
|
|
185
|
+
print(f"Generating static site from {sessions_dir} -> {out_dir} ...")
|
|
186
|
+
process_directory(sessions_dir, out_dir)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ── Main ───────────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
def main():
|
|
192
|
+
parser = argparse.ArgumentParser(
|
|
193
|
+
prog="claude-scrollback",
|
|
194
|
+
description="Lightweight viewer for Claude Code session transcripts.",
|
|
195
|
+
)
|
|
196
|
+
sub = parser.add_subparsers(dest="command", metavar="command")
|
|
197
|
+
sub.required = True
|
|
198
|
+
|
|
199
|
+
# view
|
|
200
|
+
p_view = sub.add_parser(
|
|
201
|
+
"view",
|
|
202
|
+
help="start a local server and open the session browser",
|
|
203
|
+
)
|
|
204
|
+
p_view.add_argument(
|
|
205
|
+
"path", nargs="?",
|
|
206
|
+
help="session file (.jsonl), sessions dir, or project dir "
|
|
207
|
+
"(default: ~/.claude/projects/)",
|
|
208
|
+
)
|
|
209
|
+
p_view.add_argument("-p", "--port", type=int, default=8080, help="port (default: 8080)")
|
|
210
|
+
p_view.add_argument("-n", "--no-open", action="store_true", help="don't open browser")
|
|
211
|
+
|
|
212
|
+
# show
|
|
213
|
+
p_show = sub.add_parser(
|
|
214
|
+
"show",
|
|
215
|
+
help="find and open a session by UUID (also accepts piped text)",
|
|
216
|
+
)
|
|
217
|
+
p_show.add_argument(
|
|
218
|
+
"uuid", nargs="?",
|
|
219
|
+
help="session UUID (or omit and pipe text containing UUIDs)",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
# generate
|
|
223
|
+
p_gen = sub.add_parser(
|
|
224
|
+
"generate",
|
|
225
|
+
help="generate static HTML from session files",
|
|
226
|
+
)
|
|
227
|
+
p_gen.add_argument(
|
|
228
|
+
"path", nargs="?",
|
|
229
|
+
help="session file (.jsonl), sessions dir, or project dir "
|
|
230
|
+
"(default: ~/.claude/projects/)",
|
|
231
|
+
)
|
|
232
|
+
p_gen.add_argument(
|
|
233
|
+
"-o", "--out-dir", default="_site", metavar="DIR",
|
|
234
|
+
help="output directory (default: _site/)",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
args = parser.parse_args()
|
|
238
|
+
|
|
239
|
+
if args.command == "view":
|
|
240
|
+
cmd_view(args)
|
|
241
|
+
elif args.command == "show":
|
|
242
|
+
cmd_show(args)
|
|
243
|
+
elif args.command == "generate":
|
|
244
|
+
cmd_generate(args)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|