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.
@@ -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()