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.
Files changed (69) hide show
  1. scrollback/__init__.py +8 -0
  2. scrollback/assets/icon-256.png +0 -0
  3. scrollback/assets/icon.icns +0 -0
  4. scrollback/cli.py +1139 -0
  5. scrollback/clipboard.py +34 -0
  6. scrollback/export.py +293 -0
  7. scrollback/fts.py +307 -0
  8. scrollback/highlight.py +128 -0
  9. scrollback/katexbundle.py +81 -0
  10. scrollback/launcher_install.py +209 -0
  11. scrollback/launchers/scrollback.bat +19 -0
  12. scrollback/launchers/scrollback.command +19 -0
  13. scrollback/launchers/scrollback.desktop +10 -0
  14. scrollback/launchers/scrollback.sh +12 -0
  15. scrollback/mathspan.py +180 -0
  16. scrollback/minimd.py +205 -0
  17. scrollback/models.py +135 -0
  18. scrollback/serialize.py +83 -0
  19. scrollback/serverconfig.py +66 -0
  20. scrollback/sources/__init__.py +6 -0
  21. scrollback/sources/aider.py +244 -0
  22. scrollback/sources/base.py +117 -0
  23. scrollback/sources/claudecode.py +631 -0
  24. scrollback/sources/codex.py +281 -0
  25. scrollback/sources/opencode.py +357 -0
  26. scrollback/sources/registry.py +39 -0
  27. scrollback/store.py +384 -0
  28. scrollback/termrender.py +170 -0
  29. scrollback/web/__init__.py +1 -0
  30. scrollback/web/app.py +359 -0
  31. scrollback/web/static/app.js +1245 -0
  32. scrollback/web/static/apple-touch-icon.png +0 -0
  33. scrollback/web/static/favicon.png +0 -0
  34. scrollback/web/static/favicon.svg +41 -0
  35. scrollback/web/static/index.html +75 -0
  36. scrollback/web/static/style.css +628 -0
  37. scrollback/web/static/vendor/highlight.min.js +1213 -0
  38. scrollback/web/static/vendor/hljs-dark.min.css +10 -0
  39. scrollback/web/static/vendor/hljs-light.min.css +10 -0
  40. scrollback/web/static/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  41. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  42. scrollback/web/static/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  43. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  44. scrollback/web/static/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  45. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  46. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  47. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  48. scrollback/web/static/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  49. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  50. scrollback/web/static/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  51. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  52. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  53. scrollback/web/static/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  54. scrollback/web/static/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  55. scrollback/web/static/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. scrollback/web/static/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  57. scrollback/web/static/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  58. scrollback/web/static/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  59. scrollback/web/static/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  60. scrollback/web/static/vendor/katex/katex.min.css +1 -0
  61. scrollback/web/static/vendor/katex/katex.min.js +1 -0
  62. scrollback/web/static/vendor/marked.min.js +6 -0
  63. scrollback/web/static/vendor/purify.min.js +3 -0
  64. scrollback/webopen.py +96 -0
  65. scrollback-0.1.0.dist-info/METADATA +391 -0
  66. scrollback-0.1.0.dist-info/RECORD +69 -0
  67. scrollback-0.1.0.dist-info/WHEEL +4 -0
  68. scrollback-0.1.0.dist-info/entry_points.txt +4 -0
  69. scrollback-0.1.0.dist-info/licenses/LICENSE +21 -0
scrollback/cli.py ADDED
@@ -0,0 +1,1139 @@
1
+ """scrollback command-line interface.
2
+
3
+ Subcommands:
4
+ sources list detected agents and where they read from
5
+ list list sessions (newest first), with filters
6
+ show <selector> print a session transcript to the terminal
7
+ search <query> search across sessions
8
+ export <selector> render a session to markdown/json/html/text
9
+ copy <selector> copy a rendered session to the clipboard
10
+
11
+ Selectors accept a full id, a unique prefix, `source:id`, or `latest`.
12
+ All output is plain and pipe-friendly. Reads are strictly read-only.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import os
19
+ import sys
20
+ from datetime import datetime, timezone
21
+
22
+ from . import __version__, clipboard, export, serverconfig
23
+ from .models import Session
24
+ from .sources import registry
25
+ from .store import Store
26
+
27
+
28
+ def _fmt_dt(dt: datetime | None) -> str:
29
+ return dt.strftime("%Y-%m-%d %H:%M") if dt else "?"
30
+
31
+
32
+ def _eprint(*a: object) -> None:
33
+ print(*a, file=sys.stderr)
34
+
35
+
36
+ def _nonneg_int(s: str) -> int:
37
+ """argparse type: a non-negative integer (rejects negatives)."""
38
+ try:
39
+ v = int(s)
40
+ except ValueError as exc:
41
+ raise argparse.ArgumentTypeError(f"expected an integer, got {s!r}") from exc
42
+ if v < 0:
43
+ raise argparse.ArgumentTypeError(f"must be >= 0, got {v}")
44
+ return v
45
+
46
+
47
+ def _positive_int(s: str) -> int:
48
+ """argparse type: a positive integer (>= 1)."""
49
+ v = _nonneg_int(s)
50
+ if v < 1:
51
+ raise argparse.ArgumentTypeError(f"must be >= 1, got {v}")
52
+ return v
53
+
54
+
55
+ def _parse_date(s: str | None) -> datetime | None:
56
+ """Parse a CLI date/datetime into an aware UTC datetime.
57
+
58
+ Accepts YYYY-MM-DD or full ISO-8601. Naive values are treated as UTC.
59
+ Raises argparse.ArgumentTypeError on bad input so the CLI reports it.
60
+ """
61
+ if not s:
62
+ return None
63
+ raw = s.strip()
64
+ try:
65
+ if len(raw) == 10: # YYYY-MM-DD
66
+ dt = datetime.strptime(raw, "%Y-%m-%d")
67
+ else:
68
+ iso = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
69
+ dt = datetime.fromisoformat(iso)
70
+ except ValueError as exc:
71
+ raise argparse.ArgumentTypeError(
72
+ f"invalid date {s!r}; use YYYY-MM-DD or ISO-8601"
73
+ ) from exc
74
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
75
+
76
+
77
+ def _fmt_tokens(n: int | None) -> str:
78
+ """Compact token count: 12345 -> '12.3k', 2100000 -> '2.1M'."""
79
+ if n is None:
80
+ return ""
81
+ if n < 1000:
82
+ return str(n)
83
+ if n < 1_000_000:
84
+ return f"{n / 1000:.1f}k"
85
+ return f"{n / 1_000_000:.1f}M"
86
+
87
+
88
+ def _fmt_cost(c: float | None) -> str:
89
+ return f"${c:.2f}" if c else ""
90
+
91
+
92
+ # -- subcommand implementations -------------------------------------------
93
+
94
+
95
+ def cmd_sources(args: argparse.Namespace) -> int:
96
+ any_found = False
97
+ for src in registry.all_sources():
98
+ avail = src.is_available()
99
+ any_found = any_found or avail
100
+ loc = src.location()
101
+ status = "available" if avail else "not found"
102
+ print(f"{src.name:12} {status:12} {loc if loc else ''}")
103
+ return 0 if any_found else 1
104
+
105
+
106
+ def cmd_doctor(args: argparse.Namespace) -> int:
107
+ """Print a diagnostics summary: sources, index, optional features, env."""
108
+ import platform
109
+
110
+ from . import __version__, fts
111
+
112
+ print(f"scrollback {__version__}")
113
+ print(f"python {platform.python_version()} ({platform.system()} {platform.machine()})")
114
+ import sqlite3
115
+
116
+ print(f"sqlite {sqlite3.sqlite_version}")
117
+ print()
118
+
119
+ print("sources:")
120
+ store = Store()
121
+ any_avail = False
122
+ for src in registry.all_sources():
123
+ avail = src.is_available()
124
+ any_avail = any_avail or avail
125
+ loc = src.location()
126
+ if avail:
127
+ try:
128
+ n = len(list(src.list_sessions()))
129
+ except Exception:
130
+ n = "?"
131
+ print(f" {src.name:12} available {n} sessions {loc}")
132
+ else:
133
+ print(f" {src.name:12} not found (looked in default location)")
134
+ if not any_avail:
135
+ print(" (none detected -- set SCROLLBACK_OPENCODE_DB / SCROLLBACK_CLAUDE_DIR")
136
+ print(" if your data lives outside the default locations)")
137
+ print()
138
+
139
+ print("optional features:")
140
+ print(f" full-text search (FTS5): {'yes' if fts.fts5_available() else 'no'}")
141
+ index = fts.FtsIndex()
142
+ if index.exists():
143
+ s = index.stats()
144
+ stale = "stale" if index.is_stale(store) else "fresh"
145
+ print(f" search index: built ({s['sessions']} sessions, {s['parts']} parts, {stale})")
146
+ print(f" {index.path}")
147
+ else:
148
+ print(" search index: not built (run 'scrollback index' for faster search)")
149
+ print(f" native window (pywebview): {'yes' if _pywebview_available() else 'no'}")
150
+ print(f" rich terminal output: {'yes' if _rich_available() else 'no'}")
151
+ print(f" web app (fastapi/uvicorn): {'yes' if _web_available() else 'no'}")
152
+
153
+ return 0 if any_avail else 1
154
+
155
+
156
+ def _rich_available() -> bool:
157
+ import importlib.util
158
+
159
+ return importlib.util.find_spec("rich") is not None
160
+
161
+
162
+ def _web_available() -> bool:
163
+ import importlib.util
164
+
165
+ return (
166
+ importlib.util.find_spec("fastapi") is not None
167
+ and importlib.util.find_spec("uvicorn") is not None
168
+ )
169
+
170
+
171
+ def cmd_resume(args: argparse.Namespace) -> int:
172
+ store = _make_store(args)
173
+ src, full = store._resolve(args.selector, getattr(args, "source", None))
174
+ if src is None or full is None:
175
+ _eprint(f"session not found: {args.selector}")
176
+ return 1
177
+ sess = src.load_session_meta(full)
178
+ if sess is None:
179
+ _eprint(f"session not found: {args.selector}")
180
+ return 1
181
+ cmd = src.resume_command(sess)
182
+ if not cmd:
183
+ _eprint(f"{src.name} has no by-id resume command; open the project and "
184
+ "start the agent there:")
185
+ if sess.directory:
186
+ _eprint(f" cd {sess.directory!r} && {src.name}")
187
+ return 1
188
+ if args.copy:
189
+ if clipboard.copy(cmd):
190
+ _eprint("resume command copied to clipboard")
191
+ else:
192
+ _eprint("clipboard unavailable; printing instead")
193
+ print(cmd)
194
+ else:
195
+ print(cmd)
196
+ return 0
197
+
198
+
199
+ def cmd_stats(args: argparse.Namespace) -> int:
200
+ store = _make_store(args)
201
+ if not store.sources:
202
+ _no_sessions_help(store)
203
+ return 1
204
+ st = store.stats()
205
+ if args.json:
206
+ import json
207
+
208
+ print(json.dumps({
209
+ "sessions": st.sessions,
210
+ "per_source": st.per_source,
211
+ "total_messages": st.total_messages,
212
+ "total_tokens_input": st.total_tokens_input,
213
+ "total_tokens_output": st.total_tokens_output,
214
+ "total_cost": st.total_cost,
215
+ "oldest": st.oldest.isoformat() if st.oldest else None,
216
+ "newest": st.newest.isoformat() if st.newest else None,
217
+ "top_projects": sorted(
218
+ st.per_project.items(), key=lambda kv: kv[1], reverse=True
219
+ )[:args.top],
220
+ }, indent=2, ensure_ascii=False))
221
+ return 0
222
+
223
+ span = ""
224
+ if st.oldest and st.newest:
225
+ span = f" ({_fmt_dt(st.oldest)} -> {_fmt_dt(st.newest)})"
226
+ print(f"sessions: {st.sessions}{span}")
227
+ print(f"messages: {st.total_messages}")
228
+ if st.total_tokens_input or st.total_tokens_output:
229
+ print(f"tokens: {_fmt_tokens(st.total_tokens_input)} in / "
230
+ f"{_fmt_tokens(st.total_tokens_output)} out")
231
+ if st.total_cost:
232
+ print(f"cost: ${st.total_cost:.2f}")
233
+ print()
234
+ print("by source:")
235
+ for name, count in sorted(st.per_source.items(), key=lambda kv: kv[1], reverse=True):
236
+ print(f" {name:12} {count}")
237
+ if st.per_project:
238
+ print()
239
+ print(f"top {args.top} projects:")
240
+ top = sorted(st.per_project.items(), key=lambda kv: kv[1], reverse=True)[:args.top]
241
+ for path, count in top:
242
+ base = path.rstrip("/").split("/")[-1] or path
243
+ print(f" {count:>5} {base}")
244
+ return 0
245
+
246
+
247
+ def cmd_index(args: argparse.Namespace) -> int:
248
+ from . import fts
249
+
250
+ index = fts.FtsIndex()
251
+ if args.clear:
252
+ if index.path.exists():
253
+ index.path.unlink()
254
+ _eprint(f"removed index {index.path}")
255
+ else:
256
+ _eprint("no index to remove")
257
+ return 0
258
+ if args.stats:
259
+ if not index.exists():
260
+ _eprint("no index built yet; run 'scrollback index' to build one")
261
+ return 1
262
+ s = index.stats()
263
+ print(f"index: {index.path}")
264
+ print(f"sessions: {s['sessions']} parts: {s['parts']}")
265
+ return 0
266
+ # Build / update.
267
+ if not fts.fts5_available():
268
+ _eprint(
269
+ "full-text search needs SQLite FTS5, which this Python's SQLite "
270
+ "was not built with. Search still works without an index (lexical "
271
+ "scan); no action needed."
272
+ )
273
+ return 1
274
+ store = Store()
275
+ if not store.sources:
276
+ _eprint("no sources available to index")
277
+ return 1
278
+ _eprint(f"building index at {index.path} ...")
279
+
280
+ def progress(done: int, total: int) -> None:
281
+ if done == total or done % 25 == 0:
282
+ _eprint(f" {done}/{total} sessions", )
283
+
284
+ stats = index.sync(store, progress=progress)
285
+ _eprint(
286
+ f"done: +{stats['added']} added, {stats['updated']} updated, "
287
+ f"{stats['removed']} removed, {stats['unchanged']} unchanged"
288
+ )
289
+ return 0
290
+
291
+
292
+ class _BadSource(Exception):
293
+ """Raised when an unknown --source name is given."""
294
+
295
+
296
+ def _make_store(args: argparse.Namespace) -> Store:
297
+ store = Store()
298
+ name = getattr(args, "source", None)
299
+ if name:
300
+ known = {s.name for s in registry.all_sources()}
301
+ if name not in known:
302
+ raise _BadSource(
303
+ f"unknown source {name!r}; available: {', '.join(sorted(known))}"
304
+ )
305
+ store = store.with_sources([name])
306
+ return store
307
+
308
+
309
+ def cmd_list(args: argparse.Namespace) -> int:
310
+ store = _make_store(args)
311
+ offset = args.offset
312
+ if args.page and args.page > 1:
313
+ offset = (args.page - 1) * args.limit
314
+ sessions = store.list_sessions(
315
+ directory=args.dir,
316
+ query=args.query,
317
+ since=args.since,
318
+ until=args.until,
319
+ limit=args.limit,
320
+ offset=offset,
321
+ fold_subagents=not args.no_fold,
322
+ )
323
+ if not sessions:
324
+ _no_sessions_help(store)
325
+ return 1
326
+ if args.json:
327
+ import json
328
+
329
+ def row(s: Session) -> dict[str, object]:
330
+ return {
331
+ "id": s.id,
332
+ "source": s.source,
333
+ "title": s.title,
334
+ "directory": s.directory,
335
+ "updated": s.updated.isoformat() if s.updated else None,
336
+ "model": s.model,
337
+ "agent": s.agent,
338
+ "messages": s.message_count,
339
+ "cost": s.cost,
340
+ "tokens_input": s.tokens_input,
341
+ "tokens_output": s.tokens_output,
342
+ "parent_id": s.parent_id,
343
+ "children": [row(c) for c in s.children],
344
+ }
345
+
346
+ print(json.dumps([row(s) for s in sessions], indent=2, ensure_ascii=False))
347
+ return 0
348
+
349
+ from . import termrender
350
+
351
+ if termrender.available(force=_color_force(args)):
352
+ termrender.render_list(sessions, show_usage=args.usage)
353
+ else:
354
+ if args.usage:
355
+ _eprint(
356
+ f"{'source':10} {'id':13} {'updated':16} {'msgs':>9} "
357
+ f"{'cost':>7} {'tok in/out':>14} title"
358
+ )
359
+ _print_list(sessions, show_usage=args.usage)
360
+ if offset:
361
+ _eprint(f"(offset {offset})")
362
+ return 0
363
+
364
+
365
+ def _color_force(args: argparse.Namespace) -> bool | None:
366
+ """Translate --plain into a force flag for termrender.available()."""
367
+ if getattr(args, "plain", False):
368
+ return False
369
+ return None
370
+
371
+
372
+ def _print_list(sessions: list[Session], *, show_usage: bool, indent: str = "") -> None:
373
+ for s in sessions:
374
+ msgs = f"{s.message_count:>4}" if s.message_count is not None else " ?"
375
+ usage = ""
376
+ if show_usage:
377
+ toks = f"{_fmt_tokens(s.tokens_input)}/{_fmt_tokens(s.tokens_output)}"
378
+ cost = _fmt_cost(s.cost)
379
+ usage = f" {cost:>7} {toks:>14}"
380
+ marker = "\u2514 " if indent else ""
381
+ print(
382
+ f"{indent}{marker}{s.source:10} {s.short_id:13} {_fmt_dt(s.updated):16} "
383
+ f"{msgs} msgs{usage} {s.title}"
384
+ )
385
+ if s.children:
386
+ _print_list(list(s.children), show_usage=show_usage, indent=indent + " ")
387
+
388
+
389
+ def _resolve(store: Store, args: argparse.Namespace) -> Session | None:
390
+ return store.load_session(args.selector, source=getattr(args, "source", None))
391
+
392
+
393
+ def cmd_show(args: argparse.Namespace) -> int:
394
+ store = _make_store(args)
395
+ sess = _resolve(store, args)
396
+ if sess is None:
397
+ _eprint(f"session not found: {args.selector}")
398
+ return 1
399
+ from . import termrender
400
+
401
+ if termrender.available(force=_color_force(args)):
402
+ termrender.render_transcript(
403
+ sess,
404
+ include_reasoning=args.reasoning,
405
+ include_tools=not args.no_tools,
406
+ markdown=not args.no_markdown,
407
+ )
408
+ return 0
409
+ text = export.to_text(
410
+ sess,
411
+ include_reasoning=args.reasoning,
412
+ include_tools=not args.no_tools,
413
+ )
414
+ print(text)
415
+ return 0
416
+
417
+
418
+ def _no_sessions_help(store: Store) -> None:
419
+ """Explain why a list/search is empty: no sources vs. just no matches."""
420
+ if not store.sources:
421
+ _eprint("no AI-agent sessions found -- no supported sources detected.")
422
+ _eprint("scrollback reads, by default:")
423
+ _eprint(" opencode ~/.local/share/opencode/opencode.db")
424
+ _eprint(" claudecode ~/.claude/projects/")
425
+ _eprint("Override with SCROLLBACK_OPENCODE_DB / SCROLLBACK_CLAUDE_DIR.")
426
+ _eprint("Run 'scrollback doctor' to see what was detected.")
427
+ else:
428
+ _eprint("no sessions matched.")
429
+
430
+
431
+ def _maybe_warn_stale_index(store: Store) -> None:
432
+ """Hint (once) that the FTS index is stale, so results may miss new
433
+ sessions. Cheap mtime check; no-op when there's no index."""
434
+ try:
435
+ from . import fts
436
+
437
+ index = fts.FtsIndex()
438
+ if index.exists() and index.is_stale(store):
439
+ _eprint("note: search index looks out of date; run 'scrollback index' to refresh")
440
+ except Exception: # never let a hint break search
441
+ pass
442
+
443
+
444
+ def cmd_search(args: argparse.Namespace) -> int:
445
+ store = _make_store(args)
446
+ hits = list(
447
+ store.search(
448
+ args.query,
449
+ directory=args.dir,
450
+ since=args.since,
451
+ until=args.until,
452
+ limit=args.limit,
453
+ )
454
+ )
455
+ _maybe_warn_stale_index(store)
456
+ if not hits:
457
+ _eprint("no matches")
458
+ return 1
459
+ if args.json:
460
+ import json
461
+
462
+ rows = [
463
+ {
464
+ "source": h.session.source,
465
+ "session_id": h.session.id,
466
+ "title": h.session.title,
467
+ "message_id": h.message.id,
468
+ "role": h.message.role,
469
+ "part_type": h.part.type,
470
+ "snippet": h.snippet,
471
+ }
472
+ for h in hits
473
+ ]
474
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
475
+ return 0
476
+ from . import termrender
477
+
478
+ if termrender.available(force=_color_force(args)):
479
+ termrender.render_search(hits, args.query)
480
+ else:
481
+ for h in hits:
482
+ print(f"{h.session.source}:{h.session.short_id} [{h.message.role}] {h.snippet}")
483
+ return 0
484
+
485
+
486
+ def cmd_export(args: argparse.Namespace) -> int:
487
+ store = _make_store(args)
488
+ sess = _resolve(store, args)
489
+ if sess is None:
490
+ _eprint(f"session not found: {args.selector}")
491
+ return 1
492
+ kwargs = _render_kwargs(args.format, args)
493
+ rendered = export.render(sess, args.format, **kwargs)
494
+ if args.output:
495
+ try:
496
+ with open(args.output, "w", encoding="utf-8") as fh:
497
+ fh.write(rendered)
498
+ except OSError as exc:
499
+ _eprint(f"could not write {args.output}: {exc}")
500
+ return 1
501
+ _eprint(f"wrote {args.output}")
502
+ else:
503
+ print(rendered)
504
+ return 0
505
+
506
+
507
+ def cmd_copy(args: argparse.Namespace) -> int:
508
+ store = _make_store(args)
509
+ sess = _resolve(store, args)
510
+ if sess is None:
511
+ _eprint(f"session not found: {args.selector}")
512
+ return 1
513
+ kwargs = _render_kwargs(args.format, args)
514
+ rendered = export.render(sess, args.format, **kwargs)
515
+ if clipboard.copy(rendered):
516
+ _eprint(f"copied {len(rendered)} chars ({args.format}) to clipboard")
517
+ return 0
518
+ _eprint("clipboard unavailable; printing instead")
519
+ print(rendered)
520
+ return 1
521
+
522
+
523
+ def _render_kwargs(fmt: str, args: argparse.Namespace) -> dict[str, object]:
524
+ if fmt == "json":
525
+ return {}
526
+ return {
527
+ "include_reasoning": args.reasoning,
528
+ "include_tools": not args.no_tools,
529
+ "math": getattr(args, "math", "raw"),
530
+ }
531
+
532
+
533
+ def cmd_web(args: argparse.Namespace) -> int:
534
+ try:
535
+ import uvicorn
536
+ except ModuleNotFoundError:
537
+ _eprint(
538
+ "the web app needs extra dependencies. install with:\n"
539
+ ' pip install "scrollback[web]"\n'
540
+ "or:\n"
541
+ " pip install fastapi uvicorn"
542
+ )
543
+ return 1
544
+ from .web.app import create_app
545
+
546
+ # Resolve the actual port to bind: honour the requested one, but fall back
547
+ # to the next free port if it's taken (unless --strict-port). This keeps a
548
+ # single source of truth and avoids "started on a different port than the
549
+ # URL we opened" bugs -- the resolved port is used everywhere below.
550
+ try:
551
+ port = serverconfig.resolve_port(args.host, args.port, strict=args.strict_port)
552
+ except OSError as exc:
553
+ _eprint(str(exc))
554
+ return 1
555
+ if port != args.port:
556
+ _eprint(f"port {args.port} busy; using {port} instead")
557
+ args.port = port # so downstream (app-window mode) sees the real port
558
+
559
+ url = f"http://{args.host}:{port}"
560
+
561
+ # If binding to a non-loopback address, warn loudly: the API is
562
+ # unauthenticated and would expose all local AI history to the network.
563
+ # Add the chosen host to the Host-guard allowlist so it can be reached.
564
+ loopback = {"127.0.0.1", "localhost", "::1", "0.0.0.0", ""}
565
+ allowed_hosts = None
566
+ if args.host not in loopback:
567
+ _eprint(f"WARNING: binding to non-loopback host {args.host!r}; the read-only "
568
+ "API will be reachable from the network with no authentication.")
569
+ allowed_hosts = [args.host]
570
+ elif args.host == "0.0.0.0":
571
+ _eprint("WARNING: binding to 0.0.0.0 exposes the API on all interfaces.")
572
+ allowed_hosts = [] # can't know the external hostname; disable host guard
573
+
574
+ # Desktop "app window" mode: a true native window via pywebview. Closing
575
+ # the window quits the process -> server stops -> port is freed, and there
576
+ # is no terminal. If pywebview isn't available, fall back to a browser
577
+ # window (with heartbeat auto-shutdown) instead of failing.
578
+ if getattr(args, "app", False):
579
+ if _pywebview_available():
580
+ return _run_app_window(create_app(allowed_hosts=allowed_hosts), args, url)
581
+ _eprint("native window unavailable (pywebview not installed/usable); "
582
+ "opening a browser window with auto-shutdown instead")
583
+ args.window = True
584
+ args.auto_shutdown = True # browser fallback: stop server when window closes
585
+
586
+ # Optional heartbeat auto-shutdown: stop the server shortly after the
587
+ # browser window/tab is closed (so the port is freed without Ctrl-C).
588
+ server_holder: dict[str, object] = {}
589
+
590
+ def _on_idle() -> None:
591
+ srv = server_holder.get("server")
592
+ if srv is not None:
593
+ srv.should_exit = True
594
+
595
+ if getattr(args, "auto_shutdown", False):
596
+ app = create_app(on_idle=_on_idle, idle_timeout=10.0, allowed_hosts=allowed_hosts)
597
+ else:
598
+ app = create_app(allowed_hosts=allowed_hosts)
599
+
600
+ _eprint(f"scrollback web -> {url} (read-only; Ctrl-C to stop)")
601
+ _maybe_launcher_hint(args)
602
+ if not args.no_browser:
603
+ import threading
604
+
605
+ from . import webopen
606
+
607
+ # Open after a short delay so the server is accepting connections.
608
+ # `--window` asks for a standalone window; default opens a tab.
609
+ opener = webopen.open_window if args.window else _open_tab
610
+ threading.Timer(0.8, lambda: opener(url)).start()
611
+
612
+ _background_index_refresh()
613
+
614
+ server = uvicorn.Server(
615
+ uvicorn.Config(app, host=args.host, port=port, log_level="warning")
616
+ )
617
+ server_holder["server"] = server
618
+ server.run()
619
+ return 0
620
+
621
+
622
+ def _maybe_launcher_hint(args: argparse.Namespace) -> None:
623
+ """Print a one-time tip about the double-clickable launcher.
624
+
625
+ Shown only on an interactive terminal and only for the plain `web`
626
+ command -- not the native `--app` / `--window` modes (already launched
627
+ that way) -- so it never gets in the way of scripted or app usage.
628
+ """
629
+ import sys
630
+
631
+ if getattr(args, "app", False) or getattr(args, "window", False):
632
+ return
633
+ if not sys.stderr.isatty():
634
+ return
635
+ _eprint(
636
+ "tip: 'scrollback install-launcher --app-bundle' adds a "
637
+ "double-clickable app so you can skip the terminal next time."
638
+ )
639
+
640
+
641
+ def _background_index_refresh() -> None:
642
+ """If an FTS index exists and is stale, refresh it in a daemon thread.
643
+
644
+ Opt-in by virtue of an index existing; runs off the request path so the
645
+ UI is usable immediately and shutdown isn't blocked.
646
+ """
647
+ import threading
648
+
649
+ def work() -> None:
650
+ try:
651
+ from . import fts
652
+
653
+ index = fts.FtsIndex()
654
+ store = Store()
655
+ if index.exists() and index.is_stale(store):
656
+ index.sync(store)
657
+ except Exception:
658
+ pass # best-effort; search still works via the existing index
659
+
660
+ threading.Thread(target=work, daemon=True).start()
661
+
662
+
663
+ def _pywebview_available() -> bool:
664
+ import importlib.util
665
+
666
+ return importlib.util.find_spec("webview") is not None
667
+
668
+
669
+ def _app_icon_path() -> str | None:
670
+ """Extract the bundled PNG window icon to a temp file and return its path.
671
+
672
+ Used cross-platform: pywebview's `icon` wants a filesystem path (Windows
673
+ taskbar, GTK/Qt window icon, macOS Dock). Our icon ships as package data,
674
+ so we materialize it once per run.
675
+ """
676
+ import tempfile
677
+ from importlib import resources
678
+
679
+ try:
680
+ data = resources.files("scrollback.assets").joinpath("icon-256.png").read_bytes()
681
+ except (OSError, ModuleNotFoundError, FileNotFoundError):
682
+ return None
683
+ path = os.path.join(tempfile.gettempdir(), "scrollback-icon.png")
684
+ try:
685
+ with open(path, "wb") as fh:
686
+ fh.write(data)
687
+ except OSError:
688
+ return None
689
+ return path
690
+
691
+
692
+ def _brand_macos_app() -> None:
693
+ """Brand the macOS app: menu name 'scrollback', a rich standard About
694
+ panel (version + description), and the Dock icon.
695
+
696
+ The menu-bar name and the standard About panel both read from the running
697
+ process's bundle info dict. When we run unbundled (or after the .app
698
+ runner exec's python), that's 'Python' with an empty About. We patch the
699
+ main bundle's info dict via PyObjC (already a pywebview dep on macOS) so
700
+ pywebview's *default* app menu -- the one it always creates -- gets the
701
+ right name and a useful About, instead of adding a second custom menu.
702
+ """
703
+ if sys.platform != "darwin":
704
+ return
705
+ from . import __version__
706
+
707
+ try:
708
+ from Foundation import NSBundle # type: ignore
709
+
710
+ bundle = NSBundle.mainBundle()
711
+ info = bundle.localizedInfoDictionary() or bundle.infoDictionary()
712
+ if info is not None:
713
+ info["CFBundleName"] = "scrollback"
714
+ info["CFBundleDisplayName"] = "scrollback"
715
+ # Fields the standard About panel reads:
716
+ info["CFBundleShortVersionString"] = __version__
717
+ info["CFBundleVersion"] = __version__
718
+ info["NSHumanReadableCopyright"] = (
719
+ "Navigate, search, copy, and export your AI coding-agent "
720
+ "sessions. Local-first and read-only."
721
+ )
722
+ except Exception:
723
+ pass # best-effort cosmetic; never fail the launch
724
+ # Dock icon (independent of the menu name).
725
+ icon = _app_icon_path()
726
+ if icon:
727
+ try:
728
+ from AppKit import NSApplication, NSImage # type: ignore
729
+
730
+ img = NSImage.alloc().initByReferencingFile_(icon)
731
+ if img is not None:
732
+ NSApplication.sharedApplication().setApplicationIconImage_(img)
733
+ except Exception:
734
+ pass
735
+
736
+
737
+ # Keep a reference so the Objective-C handler isn't garbage-collected while the
738
+ # menu item points at it.
739
+ _about_handler = None
740
+
741
+
742
+ def _install_macos_about_link() -> None:
743
+ """Re-point the standard 'About' menu item to a panel that includes a
744
+ clickable link to the project repo.
745
+
746
+ Runs after the Cocoa menu exists (via webview.start(func=...)). Replaces
747
+ the About item's action with one that calls
748
+ orderFrontStandardAboutPanelWithOptions: and passes a Credits attributed
749
+ string containing a real hyperlink.
750
+ """
751
+ global _about_handler
752
+ if sys.platform != "darwin":
753
+ return
754
+ try:
755
+ from AppKit import ( # type: ignore
756
+ NSApplication,
757
+ NSAttributedString,
758
+ NSFont,
759
+ NSFontAttributeName,
760
+ )
761
+ from Foundation import NSObject, NSURL # type: ignore
762
+
763
+ from . import __version__
764
+
765
+ repo = "https://github.com/a-attia/scrollback"
766
+ credits = NSAttributedString.alloc().initWithString_attributes_(
767
+ "Navigate, search, copy, and export your AI coding-agent sessions.\n"
768
+ "Local-first and read-only.\n\n",
769
+ {NSFontAttributeName: NSFont.systemFontOfSize_(11)},
770
+ )
771
+ link = NSAttributedString.alloc().initWithString_attributes_(
772
+ repo,
773
+ {
774
+ "NSLink": NSURL.URLWithString_(repo),
775
+ NSFontAttributeName: NSFont.systemFontOfSize_(11),
776
+ },
777
+ )
778
+ full = credits.mutableCopy()
779
+ full.appendAttributedString_(link)
780
+
781
+ class _AboutHandler(NSObject):
782
+ def showAbout_(self, _sender):
783
+ opts = {
784
+ "Credits": full,
785
+ "ApplicationName": "scrollback",
786
+ "Version": __version__,
787
+ "ApplicationVersion": __version__,
788
+ }
789
+ NSApplication.sharedApplication().orderFrontStandardAboutPanelWithOptions_(opts)
790
+
791
+ _about_handler = _AboutHandler.alloc().init()
792
+
793
+ # Find the About item in the app menu (first menu) and rewire it.
794
+ app = NSApplication.sharedApplication()
795
+ main_menu = app.mainMenu()
796
+ if main_menu is None or main_menu.numberOfItems() == 0:
797
+ return
798
+ app_menu = main_menu.itemAtIndex_(0).submenu()
799
+ for i in range(app_menu.numberOfItems()):
800
+ item = app_menu.itemAtIndex_(i)
801
+ action = item.action()
802
+ if action is not None and str(action) == "orderFrontStandardAboutPanel:":
803
+ item.setTarget_(_about_handler)
804
+ item.setAction_(b"showAbout:")
805
+ break
806
+ except Exception:
807
+ pass # best-effort; the plain About still works
808
+
809
+
810
+ def _open_tab(url: str) -> str:
811
+ import webbrowser
812
+
813
+ return "tab" if webbrowser.open(url) else "failed"
814
+
815
+
816
+ def cmd_install_launcher(args: argparse.Namespace) -> int:
817
+ from . import launcher_install
818
+
819
+ dest = None
820
+ if args.dest:
821
+ import pathlib
822
+
823
+ dest = pathlib.Path(args.dest).expanduser()
824
+ try:
825
+ created = launcher_install.install(
826
+ dest, desktop=args.desktop, app_bundle=args.app_bundle
827
+ )
828
+ except OSError as exc:
829
+ _eprint(f"could not install launcher: {exc}")
830
+ return 1
831
+ if not created:
832
+ _eprint("nothing was installed")
833
+ return 1
834
+ _eprint("installed launcher(s):")
835
+ for p in created:
836
+ _eprint(f" {p}")
837
+ if sys.platform == "darwin":
838
+ made_app = any(p.name.endswith(".app") for p in created)
839
+ made_cmd = any(p.name.endswith(".command") for p in created)
840
+ if made_cmd:
841
+ _eprint("tip: double-click it (first time: right-click -> Open).")
842
+ if made_cmd and not made_app:
843
+ _eprint(" for an app icon in ~/Applications, add --app-bundle")
844
+ return 0
845
+
846
+
847
+ class _AppBridge:
848
+ """JS<->Python API exposed to the pywebview window.
849
+
850
+ In a native webview the browser's own download/print plumbing isn't
851
+ available, so the frontend calls these methods (via window.pywebview.api)
852
+ to save a file through a native dialog and to print via the user's real
853
+ browser. Each method returns a small status string the JS can toast.
854
+ """
855
+
856
+ def __init__(self, url: str) -> None:
857
+ self._url = url
858
+ self.window = None # set after the window is created
859
+
860
+ def is_native(self) -> bool:
861
+ return True
862
+
863
+ def save_file(self, suggested_name: str, content: str) -> str:
864
+ """Show a native Save dialog and write `content` to the chosen path."""
865
+ import webview
866
+
867
+ try:
868
+ result = self.window.create_file_dialog(
869
+ webview.SAVE_DIALOG, save_filename=suggested_name
870
+ )
871
+ except Exception as exc: # pragma: no cover - GUI path
872
+ return f"error: {exc}"
873
+ if not result:
874
+ return "cancelled"
875
+ dest = result if isinstance(result, str) else result[0]
876
+ try:
877
+ with open(dest, "w", encoding="utf-8") as fh:
878
+ fh.write(content)
879
+ except OSError as exc: # pragma: no cover - GUI path
880
+ return f"error: {exc}"
881
+ return f"saved: {dest}"
882
+
883
+ def open_external(self, path_and_query: str) -> str:
884
+ """Open a URL on this server in the user's real browser (for printing,
885
+ which the native webview can't do reliably)."""
886
+ from . import webopen
887
+
888
+ full = self._url + path_and_query
889
+ webopen.open_window(full)
890
+ return "opened"
891
+
892
+
893
+ def _run_app_window(app: object, args: argparse.Namespace, url: str) -> int:
894
+ try:
895
+ import webview # pywebview
896
+ except ModuleNotFoundError:
897
+ _eprint(
898
+ "the desktop app window needs pywebview. install with:\n"
899
+ ' pip install "scrollback[app]"\n'
900
+ "or just run without --app to use your browser."
901
+ )
902
+ return 1
903
+ import threading
904
+
905
+ import uvicorn
906
+
907
+ server = uvicorn.Server(
908
+ uvicorn.Config(app, host=args.host, port=args.port, log_level="warning")
909
+ )
910
+ t = threading.Thread(target=server.run, daemon=True)
911
+ t.start()
912
+ _background_index_refresh()
913
+ _eprint(f"scrollback app -> {url} (read-only; close the window to quit)")
914
+ # macOS only: fix the menu-bar app name (an unbundled python process shows
915
+ # up as "Python"). No-op on other platforms, where the window title + icon
916
+ # below are what the OS uses.
917
+ _brand_macos_app()
918
+ bridge = _AppBridge(url)
919
+ # Window title is used by all backends (Windows/Linux taskbar + title bar).
920
+ window = webview.create_window("scrollback", url, width=1280, height=860, js_api=bridge)
921
+ bridge.window = window
922
+ icon = _app_icon_path() # cross-platform window/taskbar/Dock icon
923
+ start_kwargs: dict[str, object] = {}
924
+ if icon:
925
+ start_kwargs["icon"] = icon
926
+ # Run once the Cocoa menu exists to add a clickable repo link to the
927
+ # standard About panel (macOS only; no-op elsewhere).
928
+ webview.start(_install_macos_about_link, **start_kwargs) # blocks until window closed
929
+ # Window closed: stop the server and wait for the port to be released so
930
+ # an immediate relaunch can reuse it.
931
+ server.should_exit = True
932
+ t.join(timeout=5)
933
+ return 0
934
+
935
+
936
+ # -- argument parser -------------------------------------------------------
937
+
938
+
939
+ def build_parser() -> argparse.ArgumentParser:
940
+ p = argparse.ArgumentParser(
941
+ prog="scrollback",
942
+ description="Navigate, search, copy, and export AI coding-agent sessions.",
943
+ )
944
+ p.add_argument("--version", action="version", version=f"scrollback {__version__}")
945
+ sub = p.add_subparsers(dest="command", required=True)
946
+
947
+ # sources
948
+ sp = sub.add_parser("sources", help="list detected agents")
949
+ sp.set_defaults(func=cmd_sources)
950
+
951
+ # doctor
952
+ sp = sub.add_parser("doctor", help="diagnostics: sources, index, features, env")
953
+ sp.set_defaults(func=cmd_doctor)
954
+
955
+ # index
956
+ sp = sub.add_parser(
957
+ "index", help="build/update the full-text search index (optional, faster search)"
958
+ )
959
+ sp.add_argument("--stats", action="store_true", help="show index stats and exit")
960
+ sp.add_argument("--clear", action="store_true", help="delete the index and exit")
961
+ sp.set_defaults(func=cmd_index)
962
+
963
+ # common filters
964
+ def add_source_flag(sp_: argparse.ArgumentParser) -> None:
965
+ sp_.add_argument("--source", help="restrict to one source (e.g. opencode)")
966
+
967
+ # list
968
+ sp = sub.add_parser("list", help="list sessions (newest first)")
969
+ add_source_flag(sp)
970
+ sp.add_argument("--dir", help="filter by directory substring")
971
+ sp.add_argument("-q", "--query", help="filter by title substring")
972
+ sp.add_argument("--since", type=_parse_date, metavar="DATE",
973
+ help="only sessions updated on/after DATE (YYYY-MM-DD or ISO)")
974
+ sp.add_argument("--until", type=_parse_date, metavar="DATE",
975
+ help="only sessions updated on/before DATE")
976
+ sp.add_argument("-n", "--limit", type=_positive_int, default=30, help="max rows (default 30)")
977
+ sp.add_argument("--offset", type=_nonneg_int, default=0, help="skip N rows (pagination)")
978
+ sp.add_argument("--page", type=_positive_int, help="page number (uses --limit as page size)")
979
+ sp.add_argument("--usage", action="store_true", help="show cost + token columns")
980
+ sp.add_argument("--no-fold", action="store_true",
981
+ help="do not nest subagent sessions under their parent")
982
+ sp.add_argument("--plain", action="store_true", help="disable colour output")
983
+ sp.add_argument("--json", action="store_true", help="JSON output")
984
+ sp.set_defaults(func=cmd_list)
985
+
986
+ # show
987
+ sp = sub.add_parser("show", help="print a session transcript")
988
+ add_source_flag(sp)
989
+ sp.add_argument("selector", help="session id / prefix / source:id / latest")
990
+ sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
991
+ sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
992
+ sp.add_argument("--no-markdown", action="store_true",
993
+ help="render text as plain (no markdown formatting)")
994
+ sp.add_argument("--plain", action="store_true", help="disable colour output")
995
+ sp.set_defaults(func=cmd_show)
996
+
997
+ # search
998
+ sp = sub.add_parser("search", help="search across sessions")
999
+ add_source_flag(sp)
1000
+ sp.add_argument("query", help="text to search for (case-insensitive)")
1001
+ sp.add_argument("--dir", help="filter by directory substring")
1002
+ sp.add_argument("--since", type=_parse_date, metavar="DATE",
1003
+ help="only sessions updated on/after DATE")
1004
+ sp.add_argument("--until", type=_parse_date, metavar="DATE",
1005
+ help="only sessions updated on/before DATE")
1006
+ sp.add_argument("-n", "--limit", type=_positive_int, default=50, help="max hits (default 50)")
1007
+ sp.add_argument("--plain", action="store_true", help="disable colour output")
1008
+ sp.add_argument("--json", action="store_true", help="JSON output")
1009
+ sp.set_defaults(func=cmd_search)
1010
+
1011
+ # stats
1012
+ sp = sub.add_parser("stats", help="aggregate counts across your sessions")
1013
+ add_source_flag(sp)
1014
+ sp.add_argument("--top", type=_positive_int, default=10,
1015
+ help="how many top projects to show (default 10)")
1016
+ sp.add_argument("--json", action="store_true", help="JSON output")
1017
+ sp.set_defaults(func=cmd_stats)
1018
+
1019
+ # resume
1020
+ sp = sub.add_parser(
1021
+ "resume", help="print the command to resume a session in its native agent"
1022
+ )
1023
+ add_source_flag(sp)
1024
+ sp.add_argument("selector", help="session id / prefix / source:id / latest")
1025
+ sp.add_argument("--copy", action="store_true", help="copy the command to the clipboard")
1026
+ sp.set_defaults(func=cmd_resume)
1027
+
1028
+ # export
1029
+ sp = sub.add_parser("export", help="render a session to a file/stdout")
1030
+ add_source_flag(sp)
1031
+ sp.add_argument("selector", help="session id / prefix / source:id / latest")
1032
+ sp.add_argument(
1033
+ "-f", "--format", default="markdown",
1034
+ choices=sorted(set(export.FORMATS)), help="output format",
1035
+ )
1036
+ sp.add_argument("-o", "--output", help="write to file instead of stdout")
1037
+ sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
1038
+ sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
1039
+ sp.add_argument(
1040
+ "--math", default="raw", choices=list(export.MATH_MODES),
1041
+ help="LaTeX handling: raw (verbatim), latex (verbatim, never typeset), "
1042
+ "rendered (typeset with KaTeX in html export)",
1043
+ )
1044
+ sp.set_defaults(func=cmd_export)
1045
+
1046
+ # copy
1047
+ sp = sub.add_parser("copy", help="copy a rendered session to the clipboard")
1048
+ add_source_flag(sp)
1049
+ sp.add_argument("selector", help="session id / prefix / source:id / latest")
1050
+ sp.add_argument(
1051
+ "-f", "--format", default="markdown",
1052
+ choices=sorted(set(export.FORMATS)), help="render format",
1053
+ )
1054
+ sp.add_argument("--reasoning", action="store_true", help="include reasoning blocks")
1055
+ sp.add_argument("--no-tools", action="store_true", help="hide tool calls/outputs")
1056
+ sp.add_argument(
1057
+ "--math", default="raw", choices=list(export.MATH_MODES),
1058
+ help="LaTeX handling: raw (verbatim), latex (verbatim, never typeset), "
1059
+ "rendered (typeset with KaTeX in html export)",
1060
+ )
1061
+ sp.set_defaults(func=cmd_copy)
1062
+
1063
+ # web
1064
+ sp = sub.add_parser("web", help="launch the local web app (read-only)")
1065
+ sp.add_argument("--host", default=serverconfig.default_host(),
1066
+ help="bind host (default localhost; or $SCROLLBACK_HOST)")
1067
+ sp.add_argument("-p", "--port", type=int, default=serverconfig.default_port(),
1068
+ help=f"port (default {serverconfig.DEFAULT_PORT}; or $SCROLLBACK_PORT)")
1069
+ sp.add_argument("--strict-port", action="store_true",
1070
+ help="fail if the port is busy instead of picking the next free one")
1071
+ sp.add_argument("--no-browser", action="store_true", help="do not open a browser")
1072
+ sp.add_argument("--window", action="store_true",
1073
+ help="open in a standalone browser window instead of a tab")
1074
+ sp.add_argument("--app", action="store_true",
1075
+ help="open in a native desktop window (auto-closes; needs pywebview)")
1076
+ sp.add_argument("--auto-shutdown", action="store_true",
1077
+ help="stop the server shortly after the browser window is closed")
1078
+ sp.set_defaults(func=cmd_web)
1079
+
1080
+ # install-launcher
1081
+ sp = sub.add_parser(
1082
+ "install-launcher",
1083
+ help="install a double-clickable launcher for the web app",
1084
+ description="Install a launcher for the web app. With no flags, "
1085
+ "creates both the Desktop launcher and (on macOS) the .app "
1086
+ "bundle. Use --desktop or --app-bundle to create just one.",
1087
+ )
1088
+ sp.add_argument("--dest", help="where to place the launcher (default: Desktop)")
1089
+ sp.add_argument("--desktop", action="store_true",
1090
+ help="only the Desktop launcher (.command / .bat / .desktop)")
1091
+ sp.add_argument("--app-bundle", action="store_true",
1092
+ help="only the scrollback.app in ~/Applications (macOS; "
1093
+ "falls back to the Desktop launcher elsewhere)")
1094
+ sp.set_defaults(func=cmd_install_launcher)
1095
+
1096
+ return p
1097
+
1098
+
1099
+ def main(argv: list[str] | None = None) -> int:
1100
+ # Make stdout tolerant of non-UTF-8 locales (e.g. LANG=C) so transcripts
1101
+ # full of emoji/CJK don't crash with UnicodeEncodeError when piped/redirected.
1102
+ try:
1103
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
1104
+ except (AttributeError, ValueError):
1105
+ pass
1106
+
1107
+ parser = build_parser()
1108
+ args = parser.parse_args(argv)
1109
+ try:
1110
+ return args.func(args)
1111
+ except _BadSource as exc:
1112
+ _eprint(str(exc))
1113
+ return 2
1114
+ except BrokenPipeError:
1115
+ # Avoid a second BrokenPipeError + "Exception ignored" noise when Python
1116
+ # flushes stdout at shutdown (the classic `| head` case): redirect the
1117
+ # stdout fd to devnull before returning.
1118
+ try:
1119
+ devnull = os.open(os.devnull, os.O_WRONLY)
1120
+ os.dup2(devnull, sys.stdout.fileno())
1121
+ except OSError:
1122
+ pass
1123
+ return 0
1124
+ except KeyboardInterrupt:
1125
+ return 130
1126
+
1127
+
1128
+ def main_web(argv: list[str] | None = None) -> int:
1129
+ """Console entry point: `scrollback-web [options]` == `scrollback web`."""
1130
+ return main(["web", *(argv if argv is not None else sys.argv[1:])])
1131
+
1132
+
1133
+ def main_app(argv: list[str] | None = None) -> int:
1134
+ """Console entry point: `scrollback-app` == `scrollback web --app`."""
1135
+ return main(["web", "--app", *(argv if argv is not None else sys.argv[1:])])
1136
+
1137
+
1138
+ if __name__ == "__main__":
1139
+ raise SystemExit(main())