claude-code-recap 1.2.0

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,620 @@
1
+ #!/usr/bin/env python3
2
+ """recap: list recent Claude Code sessions across all projects.
3
+
4
+ Shows, per session: last activity, absolute project path, short summary,
5
+ turn count, git branch, model, session id, and a ready-to-paste resume
6
+ command. Reads only what Claude Code already writes to disk:
7
+
8
+ ~/.claude/history.jsonl fast global index (prompt, ts, path, id)
9
+ ~/.claude/projects/<enc>/<id>.jsonl full transcript (title, branch, model)
10
+
11
+ Zero dependencies (Python stdlib). Default run is instant and offline.
12
+ --smart makes ONE network call via the `claude` CLI to generate real
13
+ one-sentence summaries.
14
+ """
15
+
16
+ import argparse
17
+ import glob
18
+ import json
19
+ import os
20
+ import re
21
+ import shlex
22
+ import subprocess
23
+ import sys
24
+ from datetime import datetime, timedelta, timezone
25
+
26
+ CLAUDE_DIR = os.path.expanduser(os.environ.get("CLAUDE_CONFIG_DIR", "~/.claude"))
27
+ HISTORY = os.path.join(CLAUDE_DIR, "history.jsonl")
28
+ PROJECTS = os.path.join(CLAUDE_DIR, "projects")
29
+
30
+ # Sentinel used when a session has no recoverable project path. resume_cmd
31
+ # turns this into a clear note instead of an unrunnable `cd (unknown path)`.
32
+ UNKNOWN_PATH = "(unknown path)"
33
+
34
+ # ---------- ANSI ----------
35
+ def _tty() -> bool:
36
+ if os.environ.get("NO_COLOR") is not None:
37
+ return False
38
+ if os.environ.get("FORCE_COLOR") or os.environ.get("CLICOLOR_FORCE"):
39
+ return True
40
+ return sys.stdout.isatty()
41
+
42
+ USE_COLOR = _tty()
43
+
44
+ def c(code: str, s: str) -> str:
45
+ return f"\033[{code}m{s}\033[0m" if USE_COLOR else s
46
+
47
+ DIM = "2"
48
+ BOLD = "1"
49
+ GREEN = "32"
50
+ YELLOW = "33"
51
+ CYAN = "36"
52
+
53
+ def c256(n: int, s: str, bold: bool = False) -> str:
54
+ if not USE_COLOR:
55
+ return s
56
+ b = "1;" if bold else ""
57
+ return f"\033[{b}38;5;{n}m{s}\033[0m"
58
+
59
+ # four-level contrast hierarchy (256-color grays)
60
+ FG = 253 # primary
61
+ SEC = 248 # secondary
62
+ MUT = 242 # muted
63
+ FNT = 238 # faint
64
+ ACCENT = 74 # one accent: desaturated cyan-blue (ids, marks)
65
+ TODAY_C = 114 # soft green
66
+ YESTERDAY_C = 179 # soft amber
67
+
68
+ # muted, distinct per-project palette (256-color)
69
+ PROJ_PALETTE = [110, 150, 180, 176, 116, 222, 146, 210, 108, 139]
70
+
71
+ def proj_color(path: str) -> int:
72
+ return PROJ_PALETTE[sum(path.encode()) % len(PROJ_PALETTE)]
73
+
74
+ SPARK = "▁▂▃▄▅▆▇█"
75
+
76
+ def sparkline(counts):
77
+ mx = max(counts) if any(counts) else 1
78
+ return "".join(SPARK[min(7, int(v / mx * 7 + 0.5))] if v else " " for v in counts)
79
+
80
+ # ---------- helpers ----------
81
+ def parse_since(spec: str) -> datetime:
82
+ """'7d', '24h', '30m', '2w' -> aware UTC datetime cutoff."""
83
+ m = re.fullmatch(r"(\d+)([mhdw])", spec.strip())
84
+ if not m:
85
+ sys.exit(f"recap: invalid --since value '{spec}' (use e.g. 30m, 24h, 7d, 2w)")
86
+ n, unit = int(m.group(1)), m.group(2)
87
+ delta = {"m": timedelta(minutes=n), "h": timedelta(hours=n),
88
+ "d": timedelta(days=n), "w": timedelta(weeks=n)}[unit]
89
+ return datetime.now(timezone.utc) - delta
90
+
91
+ def iso_to_dt(s: str):
92
+ try:
93
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
94
+ except (ValueError, AttributeError):
95
+ return None
96
+
97
+ def rel_time(dt: datetime) -> str:
98
+ secs = (datetime.now(timezone.utc) - dt).total_seconds()
99
+ if secs < 0:
100
+ secs = 0
101
+ if secs < 60:
102
+ return "now"
103
+ if secs < 3600:
104
+ return f"{int(secs // 60)}m ago"
105
+ if secs < 86400:
106
+ return f"{int(secs // 3600)}h ago"
107
+ return f"{int(secs // 86400)}d ago"
108
+
109
+ def truncate(s: str, n: int) -> str:
110
+ s = re.sub(r"\s+", " ", s).strip()
111
+ return s if len(s) <= n else s[: n - 1] + "…"
112
+
113
+ def short_path(path: str, maxlen: int = 34) -> str:
114
+ home = os.path.expanduser("~")
115
+ if path.startswith(home):
116
+ path = "~" + path[len(home):]
117
+ if len(path) <= maxlen:
118
+ return path
119
+ parts = path.split("/")
120
+ # keep last two components, prefix with ellipsis
121
+ tail = "/".join(parts[-2:])
122
+ return truncate("…/" + tail, maxlen)
123
+
124
+ def pretty_model(model: str) -> str:
125
+ if not model:
126
+ return ""
127
+ m = re.match(r"claude-([a-z]+)-(\d+)(?:-(\d+))?", model)
128
+ if m:
129
+ name, major, minor = m.group(1), m.group(2), m.group(3)
130
+ return f"{name}-{major}.{minor}" if minor else f"{name}-{major}"
131
+ return model
132
+
133
+ META_PREFIXES = ("<command-name>", "<local-command", "<system-reminder", "Caveat:")
134
+
135
+ def is_real_prompt(text: str) -> bool:
136
+ t = text.lstrip()
137
+ return bool(t) and not t.startswith(META_PREFIXES)
138
+
139
+ # ---------- data loading ----------
140
+ def load_history():
141
+ """sessionId -> {project, first_prompt, first_ts, last_ts}"""
142
+ idx = {}
143
+ if not os.path.isfile(HISTORY):
144
+ return idx
145
+ with open(HISTORY, encoding="utf-8", errors="replace") as fh:
146
+ for line in fh:
147
+ line = line.strip()
148
+ if not line:
149
+ continue
150
+ try:
151
+ o = json.loads(line)
152
+ except json.JSONDecodeError:
153
+ continue
154
+ if not isinstance(o, dict):
155
+ continue # valid JSON but not an object (array, number, ...): skip
156
+ sid = o.get("sessionId")
157
+ ts = o.get("timestamp")
158
+ if not sid or not isinstance(ts, (int, float)):
159
+ continue
160
+ dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
161
+ e = idx.setdefault(sid, {"project": o.get("project"), "first_prompt": None,
162
+ "first_ts": dt, "last_ts": dt})
163
+ disp = (o.get("display") or "").strip()
164
+ if dt <= e["first_ts"]:
165
+ e["first_ts"] = dt
166
+ if is_real_prompt(disp) and not disp.startswith("/"):
167
+ e["first_prompt"] = disp
168
+ if e["first_prompt"] is None and is_real_prompt(disp) and not disp.startswith("/"):
169
+ e["first_prompt"] = disp
170
+ if dt > e["last_ts"]:
171
+ e["last_ts"] = dt
172
+ if o.get("project"):
173
+ e["project"] = o.get("project")
174
+ return idx
175
+
176
+ def peek_cwd(path: str, max_lines: int = 80):
177
+ """Cheaply pull cwd from the first lines of a transcript."""
178
+ try:
179
+ with open(path, encoding="utf-8", errors="replace") as fh:
180
+ for i, line in enumerate(fh):
181
+ if i >= max_lines:
182
+ break
183
+ if '"cwd"' not in line:
184
+ continue
185
+ try:
186
+ o = json.loads(line)
187
+ except json.JSONDecodeError:
188
+ continue
189
+ if isinstance(o, dict) and o.get("cwd"):
190
+ return o["cwd"]
191
+ except OSError:
192
+ pass
193
+ return None
194
+
195
+ def parse_session(path: str):
196
+ """Full parse of one transcript. Returns dict or None if unusable."""
197
+ d = {
198
+ "cwd": None, "ai_title": None, "first_prompt": None,
199
+ "git_branch": None, "model": None, "last_ts": None,
200
+ "user_turns": 0, "assistant_ids": set(),
201
+ }
202
+ any_line = False
203
+ try:
204
+ fh = open(path, encoding="utf-8", errors="replace")
205
+ except OSError:
206
+ return None
207
+ with fh:
208
+ for line in fh:
209
+ line = line.strip()
210
+ if not line:
211
+ continue
212
+ try:
213
+ o = json.loads(line)
214
+ except json.JSONDecodeError:
215
+ continue # broken / partial line: skip
216
+ if not isinstance(o, dict):
217
+ continue # valid JSON but not an object (array, number, ...): skip
218
+ any_line = True
219
+ t = o.get("type")
220
+ if o.get("cwd"):
221
+ d["cwd"] = o["cwd"]
222
+ if o.get("gitBranch"):
223
+ d["git_branch"] = o["gitBranch"]
224
+ ts = iso_to_dt(o.get("timestamp")) if isinstance(o.get("timestamp"), str) else None
225
+ if ts and (d["last_ts"] is None or ts > d["last_ts"]):
226
+ d["last_ts"] = ts
227
+ if t == "ai-title" and o.get("aiTitle"):
228
+ d["ai_title"] = o["aiTitle"]
229
+ elif t == "summary" and o.get("summary"): # older transcript versions
230
+ d["ai_title"] = d["ai_title"] or o["summary"]
231
+ elif t == "user" and not o.get("isSidechain"):
232
+ msg = o.get("message") or {}
233
+ content = msg.get("content")
234
+ text = None
235
+ if isinstance(content, str):
236
+ text = content
237
+ elif isinstance(content, list):
238
+ for block in content:
239
+ if isinstance(block, dict) and block.get("type") == "text":
240
+ text = block.get("text")
241
+ break
242
+ if isinstance(block, dict) and block.get("type") == "tool_result":
243
+ text = None
244
+ break
245
+ if text and is_real_prompt(text):
246
+ d["user_turns"] += 1
247
+ if d["first_prompt"] is None and not text.lstrip().startswith("/"):
248
+ d["first_prompt"] = text
249
+ elif t == "assistant" and not o.get("isSidechain"):
250
+ msg = o.get("message") or {}
251
+ if msg.get("model"):
252
+ d["model"] = msg["model"]
253
+ mid = msg.get("id")
254
+ if mid:
255
+ d["assistant_ids"].add(mid) # streaming chunks share message id
256
+ if not any_line:
257
+ return None
258
+ # turns = user prompts + distinct assistant messages (grouped by message id)
259
+ d["turns"] = d["user_turns"] + len(d["assistant_ids"])
260
+ del d["assistant_ids"]
261
+ return d
262
+
263
+ # ---------- smart summaries ----------
264
+ def smart_summaries(sessions):
265
+ """One `claude -p` call for all rows. Returns {sid: summary} or {}."""
266
+ items = []
267
+ for s in sessions:
268
+ ctx = s.get("ai_title") or ""
269
+ fp = s.get("first_prompt") or ""
270
+ items.append({"id": s["sid"][:8], "title": ctx[:150], "first_prompt": fp[:300]})
271
+ prompt = (
272
+ "For each session below, write ONE short sentence (max 12 words) saying what "
273
+ "the session was about. Reply with ONLY a JSON object mapping id to sentence, "
274
+ "no markdown fence.\n\n" + json.dumps(items, ensure_ascii=False)
275
+ )
276
+ try:
277
+ r = subprocess.run(
278
+ ["claude", "-p", "--model", "claude-haiku-4-5-20251001"],
279
+ input=prompt, capture_output=True, text=True, timeout=120,
280
+ )
281
+ if r.returncode != 0:
282
+ raise RuntimeError(r.stderr.strip()[:200])
283
+ raw = r.stdout.strip()
284
+ raw = re.sub(r"^```(?:json)?|```$", "", raw, flags=re.M).strip()
285
+ mapping = json.loads(raw)
286
+ return {s["sid"]: mapping.get(s["sid"][:8]) for s in sessions if mapping.get(s["sid"][:8])}
287
+ except FileNotFoundError:
288
+ print(c(YELLOW, "recap: `claude` CLI not found, --smart skipped"), file=sys.stderr)
289
+ except Exception as e:
290
+ reason = str(e).strip() or type(e).__name__
291
+ print(c(YELLOW, f"recap: --smart failed ({reason}), using default summaries"), file=sys.stderr)
292
+ return {}
293
+
294
+ # ---------- main ----------
295
+ def collect(args):
296
+ hist = load_history()
297
+ files = glob.glob(os.path.join(PROJECTS, "*", "*.jsonl"))
298
+ cand = []
299
+ for f in files:
300
+ sid = os.path.splitext(os.path.basename(f))[0]
301
+ try:
302
+ mtime = datetime.fromtimestamp(os.path.getmtime(f), tz=timezone.utc)
303
+ except OSError:
304
+ continue
305
+ cand.append((mtime, sid, f))
306
+ cand.sort(key=lambda x: x[0], reverse=True)
307
+
308
+ # 14-day activity histogram (by file mtime, local dates; cheap, already stat'ed)
309
+ today_local = datetime.now().astimezone().date()
310
+ day_counts = [0] * 14
311
+ for mtime, _sid, _f in cand:
312
+ age = (today_local - mtime.astimezone().date()).days
313
+ if 0 <= age < 14:
314
+ day_counts[13 - age] += 1
315
+
316
+ cutoff = parse_since(args.since) if args.since else None
317
+ needle = args.project.lower() if args.project else None
318
+ # over-fetch: file mtime can be newer than the last real message (Claude Code
319
+ # touches transcripts on compaction/title writes), so the mtime order is only
320
+ # approximate. Parse extra candidates, then sort by true internal timestamp.
321
+ fetch = args.limit * 2 + 5
322
+ rows = []
323
+ for mtime, sid, f in cand:
324
+ if cutoff and mtime < cutoff:
325
+ break # mtime >= internal ts, so sorted-desc break is safe
326
+ h = hist.get(sid)
327
+ path = (h and h.get("project")) or peek_cwd(f)
328
+ if needle and (not path or needle not in path.lower()):
329
+ continue
330
+ rows.append({"sid": sid, "file": f, "mtime": mtime, "path": path, "hist": h})
331
+ if len(rows) >= fetch:
332
+ break
333
+
334
+ out = []
335
+ for r in rows:
336
+ try:
337
+ d = parse_session(r["file"])
338
+ except Exception:
339
+ # A single corrupt transcript must never abort the whole run.
340
+ continue
341
+ if d is None:
342
+ continue
343
+ h = r["hist"] or {}
344
+ path = d["cwd"] or r["path"] or UNKNOWN_PATH
345
+ last = d["last_ts"] or h.get("last_ts") or r["mtime"]
346
+ summary = (d["ai_title"] or h.get("first_prompt") or d["first_prompt"]
347
+ or "(no prompt)")
348
+ out.append({
349
+ "sid": r["sid"], "path": path, "last": last,
350
+ "summary": summary, "turns": d["turns"],
351
+ "branch": d["git_branch"] or "", "model": pretty_model(d["model"] or ""),
352
+ "ai_title": d["ai_title"], "first_prompt": d["first_prompt"] or h.get("first_prompt"),
353
+ })
354
+ if cutoff:
355
+ out = [s for s in out if s["last"] >= cutoff]
356
+ out.sort(key=lambda s: s["last"], reverse=True)
357
+ return out[: args.limit], day_counts
358
+
359
+ def day_label(d, today):
360
+ if d == today:
361
+ return "Today", TODAY_C
362
+ if d == today - timedelta(days=1):
363
+ return "Yesterday", YESTERDAY_C
364
+ return d.strftime("%a %d.%m"), MUT
365
+
366
+ def render(sessions, args, day_counts):
367
+ if args.json:
368
+ payload = [{
369
+ "sessionId": s["sid"], "projectPath": s["path"],
370
+ "lastActive": s["last"].astimezone().isoformat(),
371
+ "summary": s["summary"], "turns": s["turns"],
372
+ "branch": s["branch"] or None, "model": s["model"] or None,
373
+ "resume": resume_cmd(s["path"], s["sid"], args.claude_flags),
374
+ } for s in sessions]
375
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
376
+ return
377
+
378
+ if not sessions:
379
+ print("No sessions found (check --since / --project filters).")
380
+ return
381
+
382
+ import shutil
383
+ term_w = min(shutil.get_terminal_size((110, 24)).columns, 130)
384
+ today = datetime.now().astimezone().date()
385
+ n_proj = len({s["path"] for s in sessions})
386
+
387
+ # ── header ────────────────────────────────────────────────────────────
388
+ left = c256(ACCENT, "◆", bold=True) + " " + c256(FG, "recap", bold=True)
389
+ meta = f"{len(sessions)} sessions · {n_proj} projects"
390
+ spark = sparkline(day_counts)
391
+ right = c256(MUT, meta) + " " + c256(FNT, "14d ") + c256(ACCENT, spark)
392
+ pad = term_w - 8 - len(meta) - 4 - len(day_counts) - 3
393
+ print()
394
+ print(left + " " * max(2, pad) + right)
395
+ print()
396
+
397
+ # ── day-grouped timeline ─────────────────────────────────────────────
398
+ prev_day = None
399
+ for i, s in enumerate(sessions, 1):
400
+ local = s["last"].astimezone()
401
+ d = local.date()
402
+ if d != prev_day:
403
+ label, col = day_label(d, today)
404
+ rule = "─" * (term_w - len(label) - 6)
405
+ print(c256(col, f"── {label} ", bold=(col != MUT)) + c256(FNT, rule))
406
+ print()
407
+ prev_day = d
408
+
409
+ dot = c256(proj_color(s["path"]), "●")
410
+ idx = c256(FNT, f"{i:>2}")
411
+ _, day_c = day_label(d, today)
412
+ tcol = day_c if day_c != MUT else SEC
413
+ time_s = c256(tcol, local.strftime("%H:%M"), bold=(day_c != MUT)) \
414
+ + c256(FNT, f" {rel_time(s['last']):>7}")
415
+ sid8 = c256(ACCENT, s["sid"][:8])
416
+
417
+ # line 1: index · dot · time · summary · id
418
+ summ_w = term_w - 2 - 2 - 2 - 14 - 2 - 8 - 4
419
+ summary = truncate(s["summary"], summ_w)
420
+ gap = term_w - 2 - 2 - 2 - 14 - 2 - len(summary) - 8 - 2
421
+ print(f" {idx} {dot} {time_s} {c256(FG, summary, bold=True)}"
422
+ + " " * max(2, gap) + sid8)
423
+
424
+ # line 2: project path · branch · model · turns
425
+ bits = [short_path(s["path"], 44)]
426
+ if s["branch"]:
427
+ bits.append("⎇ " + truncate(s["branch"], 18))
428
+ if s["model"]:
429
+ bits.append(s["model"])
430
+ if s["turns"] == 1:
431
+ bits.append("1 turn")
432
+ else:
433
+ bits.append(f"{s['turns']} turns" if s["turns"] else "? turns")
434
+ print(" " * 13 + c256(MUT, " · ".join(bits)))
435
+
436
+ # line 3: resume command (spaces-only indent, ~ shortened, safe to copy)
437
+ home = os.path.expanduser("~")
438
+ rp = s["path"]
439
+ if rp == home or rp.startswith(home + "/"):
440
+ rp = "~" + rp[len(home):]
441
+ print(" " * 13 + c256(FNT, resume_cmd(rp, s["sid"], args.claude_flags)))
442
+ if i < len(sessions):
443
+ print()
444
+
445
+ # ── footer ───────────────────────────────────────────────────────────
446
+ print()
447
+ print(c256(FNT, " turns ≈ user + assistant messages"
448
+ " · --pick jump in · --open all in tabs · --smart 1-line summaries"))
449
+
450
+ def _shquote_path(path: str) -> str:
451
+ """Shell-quote a path so metacharacters or spaces in a directory name cannot
452
+ break out of (or break) the resume command. A leading ~ / ~/ is kept
453
+ unquoted so the copyable line still expands to the home directory when
454
+ pasted; only the remainder is quoted."""
455
+ if path == "~":
456
+ return "~"
457
+ if path.startswith("~/"):
458
+ rest = path[2:]
459
+ return "~/" + shlex.quote(rest) if rest else "~/"
460
+ return shlex.quote(path)
461
+
462
+ def resume_cmd(path: str, sid: str, extra: str = "") -> str:
463
+ # No usable path: emit a clear, inert note instead of `cd (unknown path)`,
464
+ # which would neither run nor be safe to paste.
465
+ if not path or path == UNKNOWN_PATH:
466
+ return f"# recap: no project path recorded for session {sid}"
467
+ flags = f"{extra} " if extra else ""
468
+ # `extra` is the user's own --claude-flags, passed through verbatim (it may
469
+ # legitimately hold several space-separated flags). `path` and `sid` come
470
+ # from disk, so they are shell-quoted to prevent injection.
471
+ return f"cd {_shquote_path(path)} && claude {flags}-r {shlex.quote(sid)}"
472
+
473
+ # ---------- open in terminal tabs ----------
474
+ def _osa_str(s: str) -> str:
475
+ """Quote a Python string as an AppleScript string literal."""
476
+ return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
477
+
478
+ def open_tabs(sessions, args):
479
+ """Open one new terminal tab per session and resume it there."""
480
+ # osascript drives iTerm2/Terminal, so real tab opening is macOS-only.
481
+ # --dry-run stays available everywhere (it prints, it does not open).
482
+ if sys.platform != "darwin" and not args.dry_run:
483
+ sys.exit("recap: --open drives iTerm2/Terminal via osascript and only works on macOS. "
484
+ "Use --dry-run to print the resume commands instead.")
485
+ current = os.environ.get("CLAUDE_CODE_SESSION_ID")
486
+ targets, skipped = [], []
487
+ for s in sessions:
488
+ if s["sid"] == current:
489
+ continue # never re-open the session running recap
490
+ if not os.path.isdir(s["path"]):
491
+ skipped.append(s)
492
+ continue
493
+ targets.append(s)
494
+
495
+ if not targets:
496
+ print(c(YELLOW, "recap: nothing to open"), file=sys.stderr)
497
+ return
498
+
499
+ extra = (args.claude_flags or "").strip()
500
+ cmds = [resume_cmd(s["path"], s["sid"], extra) for s in targets]
501
+
502
+ print()
503
+ print(c256(FG, f"Opening {len(targets)} tab(s):", bold=True))
504
+ for s, cmd in zip(targets, cmds):
505
+ print(" " + c256(MUT, truncate(s["summary"], 46).ljust(48)) + c256(FNT, cmd))
506
+ for s in skipped:
507
+ print(c(YELLOW, f" skipped (path gone): {s['path']}"))
508
+
509
+ if "--dangerously-skip-permissions" in extra:
510
+ print()
511
+ print(c(YELLOW, " WARNING: --dangerously-skip-permissions disables all permission "
512
+ "checks in every tab opened."))
513
+
514
+ if args.dry_run:
515
+ return
516
+
517
+ if not args.yes:
518
+ if not sys.stdin.isatty():
519
+ sys.exit("recap: --open needs confirmation; re-run with --yes (non-interactive stdin)")
520
+ try:
521
+ if input("\nProceed? [y/N] ").strip().lower() not in ("y", "yes"):
522
+ print("aborted")
523
+ return
524
+ except (EOFError, KeyboardInterrupt):
525
+ print("\naborted")
526
+ return
527
+
528
+ app = args.terminal
529
+ if app == "auto":
530
+ app = "iTerm2" if os.path.isdir("/Applications/iTerm.app") else "Terminal"
531
+
532
+ lines = [f'tell application "{app}" to activate']
533
+ for cmd in cmds:
534
+ if app == "iTerm2":
535
+ lines += [
536
+ 'tell application "iTerm2"',
537
+ " tell current window",
538
+ " create tab with default profile",
539
+ f" tell current session to write text {_osa_str(cmd)}",
540
+ " end tell",
541
+ "end tell",
542
+ "delay 0.4",
543
+ ]
544
+ else:
545
+ lines += [
546
+ 'tell application "Terminal"',
547
+ f" do script {_osa_str(cmd)}",
548
+ "end tell",
549
+ "delay 0.4",
550
+ ]
551
+ script = "\n".join(lines)
552
+
553
+ r = subprocess.run(["osascript", "-"], input=script, capture_output=True, text=True)
554
+ if r.returncode != 0:
555
+ sys.exit(f"recap: osascript failed: {r.stderr.strip()[:300]}")
556
+ print(c256(TODAY_C, f"\nOpened {len(targets)} tab(s) in {app}"))
557
+
558
+ def pick(sessions):
559
+ try:
560
+ choice = input("\nJump to session # (empty to quit): ").strip()
561
+ except (EOFError, KeyboardInterrupt):
562
+ return
563
+ if not choice:
564
+ return
565
+ try:
566
+ s = sessions[int(choice) - 1]
567
+ except (ValueError, IndexError):
568
+ sys.exit("recap: invalid selection")
569
+ path = s["path"]
570
+ if not os.path.isdir(path):
571
+ sys.exit(f"recap: project path no longer exists: {path}")
572
+ os.chdir(path)
573
+ os.execvp("claude", ["claude", "-r", s["sid"]])
574
+
575
+ def main():
576
+ ap = argparse.ArgumentParser(prog="recap",
577
+ description="List recent Claude Code sessions across all projects.")
578
+ ap.add_argument("--since", metavar="SPEC", help="only sessions active within SPEC (30m, 24h, 7d, 2w)")
579
+ ap.add_argument("--project", metavar="SUBSTR", help="filter by project path substring")
580
+ ap.add_argument("--limit", type=int, default=15, metavar="N", help="max sessions (default 15)")
581
+ ap.add_argument("--json", action="store_true", help="machine-readable output (full session ids)")
582
+ ap.add_argument("--smart", action="store_true",
583
+ help="generate 1-sentence summaries via one `claude -p` call (network)")
584
+ ap.add_argument("--pick", action="store_true", help="interactively pick a session and resume it")
585
+ ap.add_argument("--open", dest="open_tabs", action="store_true",
586
+ help="open every listed session in its own terminal tab and resume it")
587
+ ap.add_argument("--claude-flags", metavar="FLAGS", default="",
588
+ help='extra flags for the resumed `claude` (e.g. "--chrome --dangerously-skip-permissions")')
589
+ ap.add_argument("--terminal", choices=["auto", "iTerm2", "Terminal"], default="auto",
590
+ help="terminal app used by --open (default: auto)")
591
+ ap.add_argument("--yes", "-y", action="store_true", help="skip the --open confirmation prompt")
592
+ ap.add_argument("--dry-run", action="store_true", help="with --open: print what would run, open nothing")
593
+ ap.add_argument("--color", action="store_true", help="force ANSI colors even when piped")
594
+ ap.add_argument("--plain", action="store_true", help="disable all ANSI colors")
595
+ args = ap.parse_args()
596
+
597
+ global USE_COLOR
598
+ if args.plain:
599
+ USE_COLOR = False
600
+ elif args.color:
601
+ USE_COLOR = True
602
+
603
+ if not os.path.isdir(PROJECTS):
604
+ sys.exit(f"recap: {PROJECTS} not found. Is Claude Code installed on this machine? "
605
+ "Set CLAUDE_CONFIG_DIR if your config lives somewhere other than ~/.claude.")
606
+
607
+ sessions, day_counts = collect(args)
608
+ if args.smart and sessions:
609
+ for sid, summ in smart_summaries(sessions).items():
610
+ for s in sessions:
611
+ if s["sid"] == sid:
612
+ s["summary"] = summ
613
+ render(sessions, args, day_counts)
614
+ if args.open_tabs and sessions and not args.json:
615
+ open_tabs(sessions, args)
616
+ elif args.pick and sessions and not args.json:
617
+ pick(sessions)
618
+
619
+ if __name__ == "__main__":
620
+ main()