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.
- package/LICENSE +21 -0
- package/README.md +279 -0
- package/bin/cli.mjs +286 -0
- package/install.sh +249 -0
- package/package.json +55 -0
- package/skills/recap/SKILL.md +103 -0
- package/skills/recap/recap.py +620 -0
|
@@ -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()
|