clockwork-cli 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.
- clockwork_cli/__init__.py +930 -0
- clockwork_cli/__main__.py +3 -0
- clockwork_cli-0.1.0.dist-info/METADATA +272 -0
- clockwork_cli-0.1.0.dist-info/RECORD +8 -0
- clockwork_cli-0.1.0.dist-info/WHEEL +5 -0
- clockwork_cli-0.1.0.dist-info/entry_points.txt +2 -0
- clockwork_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- clockwork_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
"""
|
|
2
|
+
clockwork — analyze Claude Code / Codex session activity.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
clockwork <provider> <project-path> [idle-min] [options] Analyze one project
|
|
6
|
+
clockwork <provider> all [idle-min] [options] Rank all projects
|
|
7
|
+
clockwork <provider> today [idle-min] [options] Today, all projects
|
|
8
|
+
clockwork <provider> week [idle-min] [options] Last 7 days, all projects
|
|
9
|
+
clockwork <provider> export [idle-min] [options] Bundle all projects as JSON
|
|
10
|
+
clockwork <provider> list [options] List project folders
|
|
11
|
+
|
|
12
|
+
<provider> is one of: claude | codex | both
|
|
13
|
+
|
|
14
|
+
both merges Claude and Codex activity per project, so every command above
|
|
15
|
+
works across both tools at once.
|
|
16
|
+
|
|
17
|
+
today / week use your local day boundaries and aggregate every project into
|
|
18
|
+
one summary — the "how much have I done" daily check-in. export writes a
|
|
19
|
+
single self-describing JSON bundle for uploading to external tools.
|
|
20
|
+
|
|
21
|
+
Options:
|
|
22
|
+
--json Emit machine-readable JSON instead of ASCII tables
|
|
23
|
+
--since <when> Only count prompts on/after this point in time
|
|
24
|
+
--until <when> Only count prompts on/before this point in time
|
|
25
|
+
<when> is YYYY-MM-DD, an ISO timestamp, or a relative
|
|
26
|
+
form like 7d (7 days ago) or 2w (2 weeks ago)
|
|
27
|
+
--detail <level> export granularity: raw | sessions | daily (default raw)
|
|
28
|
+
--anonymize export: replace project paths with a hash id + generic name
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
clockwork claude ~/code/myproject
|
|
32
|
+
clockwork codex ~/code/myproject 45
|
|
33
|
+
clockwork codex all --json
|
|
34
|
+
clockwork claude today
|
|
35
|
+
clockwork codex week --json
|
|
36
|
+
clockwork both all
|
|
37
|
+
clockwork both today
|
|
38
|
+
clockwork claude export > clockwork.json
|
|
39
|
+
clockwork codex export --detail sessions --anonymize > share.json
|
|
40
|
+
clockwork claude all --since 7d
|
|
41
|
+
clockwork claude ~/code/myproject --since 2026-07-01 --until 2026-07-05
|
|
42
|
+
clockwork claude list
|
|
43
|
+
|
|
44
|
+
Data sources:
|
|
45
|
+
claude → ~/.claude/projects/<encoded-path>/*.jsonl
|
|
46
|
+
codex → $CODEX_HOME/sessions/**/rollout-*.jsonl (default ~/.codex/sessions)
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
import sys
|
|
50
|
+
import json
|
|
51
|
+
import os
|
|
52
|
+
import re
|
|
53
|
+
import glob
|
|
54
|
+
import hashlib
|
|
55
|
+
from datetime import datetime, timedelta, timezone
|
|
56
|
+
from collections import defaultdict
|
|
57
|
+
|
|
58
|
+
__version__ = "0.1.0"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_claude_dir():
|
|
62
|
+
if sys.platform == "win32":
|
|
63
|
+
appdata = os.environ.get("APPDATA")
|
|
64
|
+
if appdata:
|
|
65
|
+
candidate = os.path.join(appdata, "Claude", "projects")
|
|
66
|
+
if os.path.isdir(candidate):
|
|
67
|
+
return candidate
|
|
68
|
+
return os.path.join(os.path.expanduser("~"), ".claude", "projects")
|
|
69
|
+
return os.path.expanduser("~/.claude/projects")
|
|
70
|
+
|
|
71
|
+
CLAUDE_DIR = _resolve_claude_dir()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# --------------------------------------------------------------------------- #
|
|
75
|
+
# Shared timing logic
|
|
76
|
+
# --------------------------------------------------------------------------- #
|
|
77
|
+
|
|
78
|
+
def group_sessions(timestamps, idle_threshold_minutes=30):
|
|
79
|
+
"""Group sorted prompt timestamps into (start, end, prompt_count) sessions.
|
|
80
|
+
|
|
81
|
+
A gap larger than the idle threshold starts a new session.
|
|
82
|
+
"""
|
|
83
|
+
if not timestamps:
|
|
84
|
+
return []
|
|
85
|
+
threshold = timedelta(minutes=idle_threshold_minutes)
|
|
86
|
+
sessions = []
|
|
87
|
+
start = end = timestamps[0]
|
|
88
|
+
count = 1
|
|
89
|
+
for ts in timestamps[1:]:
|
|
90
|
+
if ts - end > threshold:
|
|
91
|
+
sessions.append((start, end, count))
|
|
92
|
+
start = ts
|
|
93
|
+
count = 0
|
|
94
|
+
end = ts
|
|
95
|
+
count += 1
|
|
96
|
+
sessions.append((start, end, count))
|
|
97
|
+
return sessions
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def compute_active_hours(timestamps, idle_threshold_minutes=30):
|
|
101
|
+
"""Return (per-day active minutes, [(start, end), ...] sessions).
|
|
102
|
+
|
|
103
|
+
Session duration is split across calendar-day boundaries so a session
|
|
104
|
+
crossing midnight is credited to each day it actually spans.
|
|
105
|
+
"""
|
|
106
|
+
sessions = group_sessions(timestamps, idle_threshold_minutes)
|
|
107
|
+
daily = defaultdict(float)
|
|
108
|
+
for start, end, _ in sessions:
|
|
109
|
+
_add_session_to_daily(daily, start, end)
|
|
110
|
+
return daily, [(start, end) for start, end, _ in sessions]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _add_session_to_daily(daily, start, end):
|
|
114
|
+
"""Distribute a session's minutes across the calendar days it spans."""
|
|
115
|
+
total_min = (end - start).total_seconds() / 60
|
|
116
|
+
if total_min < 1:
|
|
117
|
+
# Zero-length (or sub-minute) session: floor at 1 minute on its day.
|
|
118
|
+
daily[start.date()] += 1
|
|
119
|
+
return
|
|
120
|
+
cursor = start
|
|
121
|
+
while cursor < end:
|
|
122
|
+
next_midnight = datetime.combine(
|
|
123
|
+
cursor.date() + timedelta(days=1),
|
|
124
|
+
datetime.min.time(),
|
|
125
|
+
tzinfo=cursor.tzinfo,
|
|
126
|
+
)
|
|
127
|
+
seg_end = min(end, next_midnight)
|
|
128
|
+
daily[cursor.date()] += (seg_end - cursor).total_seconds() / 60
|
|
129
|
+
cursor = seg_end
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def fmt_duration(minutes):
|
|
133
|
+
h = int(minutes) // 60
|
|
134
|
+
m = int(minutes) % 60
|
|
135
|
+
if h > 0:
|
|
136
|
+
return f"{h}h {m:02d}m"
|
|
137
|
+
return f"{m}m"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _parse_ts(value):
|
|
141
|
+
if not value:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
145
|
+
except (ValueError, TypeError, AttributeError):
|
|
146
|
+
return None
|
|
147
|
+
# Normalize everything to UTC-aware so timestamps from mixed sources
|
|
148
|
+
# (some tz-aware, some naive) can be safely sorted and subtracted.
|
|
149
|
+
if dt.tzinfo is None:
|
|
150
|
+
return dt.replace(tzinfo=timezone.utc)
|
|
151
|
+
return dt.astimezone(timezone.utc)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parse_date_bound(value, end_of_day=False):
|
|
155
|
+
"""Parse a --since/--until value into a UTC-aware datetime.
|
|
156
|
+
|
|
157
|
+
Accepts a relative form (`7d`, `2w`) meaning that many days/weeks before
|
|
158
|
+
now, a bare ISO date (`YYYY-MM-DD`), or a full ISO timestamp. A bare date
|
|
159
|
+
used as an upper bound snaps to the end of that day so the day is inclusive.
|
|
160
|
+
"""
|
|
161
|
+
if not value:
|
|
162
|
+
return None
|
|
163
|
+
value = value.strip()
|
|
164
|
+
m = re.fullmatch(r"(\d+)([dw])", value.lower())
|
|
165
|
+
if m:
|
|
166
|
+
n = int(m.group(1))
|
|
167
|
+
delta = timedelta(weeks=n) if m.group(2) == "w" else timedelta(days=n)
|
|
168
|
+
return datetime.now(timezone.utc) - delta
|
|
169
|
+
try:
|
|
170
|
+
dt = datetime.strptime(value, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
|
171
|
+
if end_of_day:
|
|
172
|
+
dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
173
|
+
return dt
|
|
174
|
+
except ValueError:
|
|
175
|
+
pass
|
|
176
|
+
dt = _parse_ts(value)
|
|
177
|
+
if dt is None:
|
|
178
|
+
raise ValueError(
|
|
179
|
+
f"unrecognized date {value!r} (use YYYY-MM-DD, an ISO timestamp, "
|
|
180
|
+
f"or a relative form like 7d / 2w)"
|
|
181
|
+
)
|
|
182
|
+
return dt
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _filter_timestamps(timestamps, since, until):
|
|
186
|
+
"""Keep only timestamps within [since, until] (either bound may be None)."""
|
|
187
|
+
if since is None and until is None:
|
|
188
|
+
return timestamps
|
|
189
|
+
return [
|
|
190
|
+
ts for ts in timestamps
|
|
191
|
+
if (since is None or ts >= since) and (until is None or ts <= until)
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _iso(dt):
|
|
196
|
+
return dt.isoformat() if dt else None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _print_json(obj):
|
|
200
|
+
print(json.dumps(obj, indent=2))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _project_id(path):
|
|
204
|
+
"""Stable short id for a project path (survives anonymization)."""
|
|
205
|
+
return hashlib.sha1(path.encode("utf-8")).hexdigest()[:8]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _display_name(path):
|
|
209
|
+
return os.path.basename(path.rstrip("/\\")) or path
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
# --------------------------------------------------------------------------- #
|
|
213
|
+
# Claude backend (~/.claude/projects/<encoded-path>/*.jsonl)
|
|
214
|
+
# --------------------------------------------------------------------------- #
|
|
215
|
+
|
|
216
|
+
def path_to_claude_folder(project_path):
|
|
217
|
+
abs_path = os.path.abspath(os.path.expanduser(project_path))
|
|
218
|
+
folder_name = abs_path.replace("/", "-").replace("\\", "-").replace(":", "-")
|
|
219
|
+
if folder_name.startswith("-"):
|
|
220
|
+
folder_name = folder_name[1:]
|
|
221
|
+
return folder_name
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _claude_resolve_files(project_path):
|
|
225
|
+
"""Return (folder_name, [files]) for a project path, or (None, [])."""
|
|
226
|
+
folder = path_to_claude_folder(project_path)
|
|
227
|
+
# Claude Code retains the leading dash from the absolute path, so try both
|
|
228
|
+
# the dash-prefixed and stripped forms to match whichever convention exists.
|
|
229
|
+
for candidate in ("-" + folder, folder):
|
|
230
|
+
files = glob.glob(os.path.join(CLAUDE_DIR, candidate, "*.jsonl"))
|
|
231
|
+
if files:
|
|
232
|
+
return candidate, files
|
|
233
|
+
return None, []
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _claude_timestamps_from_files(files):
|
|
237
|
+
"""Parse user/human turn timestamps from Claude .jsonl files."""
|
|
238
|
+
timestamps = []
|
|
239
|
+
for f in sorted(files):
|
|
240
|
+
with open(f) as fh:
|
|
241
|
+
for line in fh:
|
|
242
|
+
line = line.strip()
|
|
243
|
+
if not line:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
d = json.loads(line)
|
|
247
|
+
except json.JSONDecodeError:
|
|
248
|
+
continue
|
|
249
|
+
role = d.get("role") or d.get("type", "")
|
|
250
|
+
dt = _parse_ts(d.get("timestamp"))
|
|
251
|
+
if dt and role in ("human", "user"):
|
|
252
|
+
timestamps.append(dt)
|
|
253
|
+
return sorted(timestamps)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _claude_folder_to_pretty(folder):
|
|
257
|
+
"""Best-effort readable path from a Claude folder name (display only)."""
|
|
258
|
+
if sys.platform == "win32":
|
|
259
|
+
return folder
|
|
260
|
+
return "/" + folder.lstrip("-").replace("-", "/")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def claude_single(project_path):
|
|
264
|
+
folder, files = _claude_resolve_files(project_path)
|
|
265
|
+
if not files:
|
|
266
|
+
return None, 0, []
|
|
267
|
+
return folder, len(files), _claude_timestamps_from_files(files)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def claude_all():
|
|
271
|
+
result = []
|
|
272
|
+
for folder in _claude_list_folders():
|
|
273
|
+
files = glob.glob(os.path.join(CLAUDE_DIR, folder, "*.jsonl"))
|
|
274
|
+
ts = _claude_timestamps_from_files(files)
|
|
275
|
+
if ts:
|
|
276
|
+
result.append((_claude_folder_to_pretty(folder), ts))
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _claude_list_folders():
|
|
281
|
+
return sorted(os.listdir(CLAUDE_DIR)) if os.path.exists(CLAUDE_DIR) else []
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def claude_list():
|
|
285
|
+
return _claude_list_folders()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# --------------------------------------------------------------------------- #
|
|
289
|
+
# Codex backend ($CODEX_HOME/sessions/**/rollout-*.jsonl)
|
|
290
|
+
# --------------------------------------------------------------------------- #
|
|
291
|
+
|
|
292
|
+
def _codex_files():
|
|
293
|
+
codex_home = os.environ.get("CODEX_HOME", os.path.expanduser("~/.codex"))
|
|
294
|
+
pattern = os.path.join(codex_home, "sessions", "**", "rollout-*.jsonl")
|
|
295
|
+
return glob.glob(pattern, recursive=True)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _codex_parse_file(path):
|
|
299
|
+
"""Return (cwd, [timestamps]) for one rollout file.
|
|
300
|
+
|
|
301
|
+
Uses the canonical `event_msg -> user_message` prompt records; only falls
|
|
302
|
+
back to `response_item` user-role messages for files that contain no
|
|
303
|
+
canonical prompt events, to avoid double counting the same input.
|
|
304
|
+
"""
|
|
305
|
+
cwd = None
|
|
306
|
+
canonical = []
|
|
307
|
+
fallback = []
|
|
308
|
+
try:
|
|
309
|
+
with open(path) as fh:
|
|
310
|
+
for line in fh:
|
|
311
|
+
line = line.strip()
|
|
312
|
+
if not line:
|
|
313
|
+
continue
|
|
314
|
+
try:
|
|
315
|
+
d = json.loads(line)
|
|
316
|
+
except json.JSONDecodeError:
|
|
317
|
+
continue
|
|
318
|
+
t = d.get("type")
|
|
319
|
+
payload = d.get("payload") or {}
|
|
320
|
+
if t == "session_meta":
|
|
321
|
+
cwd = payload.get("cwd")
|
|
322
|
+
elif t == "event_msg" and payload.get("type") == "user_message":
|
|
323
|
+
dt = _parse_ts(d.get("timestamp") or payload.get("timestamp"))
|
|
324
|
+
if dt:
|
|
325
|
+
canonical.append(dt)
|
|
326
|
+
elif (t == "response_item"
|
|
327
|
+
and payload.get("type") == "message"
|
|
328
|
+
and payload.get("role") == "user"):
|
|
329
|
+
dt = _parse_ts(d.get("timestamp") or payload.get("timestamp"))
|
|
330
|
+
if dt:
|
|
331
|
+
fallback.append(dt)
|
|
332
|
+
except OSError:
|
|
333
|
+
return None, []
|
|
334
|
+
return cwd, sorted(canonical if canonical else fallback)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def codex_single(project_path):
|
|
338
|
+
abs_path = os.path.abspath(os.path.expanduser(project_path))
|
|
339
|
+
matched = 0
|
|
340
|
+
timestamps = []
|
|
341
|
+
for f in _codex_files():
|
|
342
|
+
cwd, file_ts = _codex_parse_file(f)
|
|
343
|
+
if cwd == abs_path:
|
|
344
|
+
matched += 1
|
|
345
|
+
timestamps.extend(file_ts)
|
|
346
|
+
if not matched:
|
|
347
|
+
return None, 0, []
|
|
348
|
+
return abs_path, matched, sorted(timestamps)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def codex_all():
|
|
352
|
+
groups = defaultdict(list)
|
|
353
|
+
for f in _codex_files():
|
|
354
|
+
cwd, file_ts = _codex_parse_file(f)
|
|
355
|
+
if cwd and file_ts:
|
|
356
|
+
groups[cwd].extend(file_ts)
|
|
357
|
+
return [(cwd, sorted(ts)) for cwd, ts in groups.items()]
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _codex_cwd_only(path):
|
|
361
|
+
"""Return just the session's cwd, reading only until the meta record.
|
|
362
|
+
|
|
363
|
+
`list` doesn't need prompt timestamps, so avoid parsing the whole file:
|
|
364
|
+
the `session_meta` record carrying `cwd` is at the top of the rollout.
|
|
365
|
+
"""
|
|
366
|
+
try:
|
|
367
|
+
with open(path) as fh:
|
|
368
|
+
for line in fh:
|
|
369
|
+
line = line.strip()
|
|
370
|
+
if not line:
|
|
371
|
+
continue
|
|
372
|
+
try:
|
|
373
|
+
d = json.loads(line)
|
|
374
|
+
except json.JSONDecodeError:
|
|
375
|
+
continue
|
|
376
|
+
if d.get("type") == "session_meta":
|
|
377
|
+
return (d.get("payload") or {}).get("cwd")
|
|
378
|
+
except OSError:
|
|
379
|
+
return None
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def codex_list():
|
|
384
|
+
cwds = set()
|
|
385
|
+
for f in _codex_files():
|
|
386
|
+
cwd = _codex_cwd_only(f)
|
|
387
|
+
if cwd:
|
|
388
|
+
cwds.add(cwd)
|
|
389
|
+
return sorted(cwds)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# --------------------------------------------------------------------------- #
|
|
393
|
+
# Combined backend (claude + codex merged per project)
|
|
394
|
+
# --------------------------------------------------------------------------- #
|
|
395
|
+
|
|
396
|
+
def _merge_projects(*project_lists):
|
|
397
|
+
"""Merge [(label, timestamps)] lists, combining same-label projects."""
|
|
398
|
+
groups = defaultdict(list)
|
|
399
|
+
for projects in project_lists:
|
|
400
|
+
for label, ts in projects:
|
|
401
|
+
groups[label].extend(ts)
|
|
402
|
+
return [(label, sorted(ts)) for label, ts in groups.items()]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def both_single(project_path):
|
|
406
|
+
_, c_count, c_ts = claude_single(project_path)
|
|
407
|
+
_, x_count, x_ts = codex_single(project_path)
|
|
408
|
+
count = c_count + x_count
|
|
409
|
+
if count == 0:
|
|
410
|
+
return None, 0, []
|
|
411
|
+
return project_path, count, sorted(c_ts + x_ts)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def both_all():
|
|
415
|
+
return _merge_projects(claude_all(), codex_all())
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def both_list():
|
|
419
|
+
labels = {_claude_folder_to_pretty(f) for f in _claude_list_folders()}
|
|
420
|
+
labels.update(codex_list())
|
|
421
|
+
return sorted(labels)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# --------------------------------------------------------------------------- #
|
|
425
|
+
# Provider dispatch
|
|
426
|
+
# --------------------------------------------------------------------------- #
|
|
427
|
+
|
|
428
|
+
PROVIDERS = {
|
|
429
|
+
"claude": {
|
|
430
|
+
"single": claude_single,
|
|
431
|
+
"all": claude_all,
|
|
432
|
+
"list": claude_list,
|
|
433
|
+
"location": "~/.claude/projects/",
|
|
434
|
+
},
|
|
435
|
+
"codex": {
|
|
436
|
+
"single": codex_single,
|
|
437
|
+
"all": codex_all,
|
|
438
|
+
"list": codex_list,
|
|
439
|
+
"location": "$CODEX_HOME/sessions/",
|
|
440
|
+
},
|
|
441
|
+
"both": {
|
|
442
|
+
"single": both_single,
|
|
443
|
+
"all": both_all,
|
|
444
|
+
"list": both_list,
|
|
445
|
+
"location": "claude + codex",
|
|
446
|
+
},
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _provider_label(provider):
|
|
451
|
+
"""Human display name for headers ('both' reads better spelled out)."""
|
|
452
|
+
return "CLAUDE + CODEX" if provider == "both" else provider.upper()
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# --------------------------------------------------------------------------- #
|
|
456
|
+
# Output modes
|
|
457
|
+
# --------------------------------------------------------------------------- #
|
|
458
|
+
|
|
459
|
+
def analyze_single(provider, project_path, idle_threshold, since, until, as_json):
|
|
460
|
+
label, count, timestamps = PROVIDERS[provider]["single"](project_path)
|
|
461
|
+
timestamps = _filter_timestamps(timestamps, since, until)
|
|
462
|
+
|
|
463
|
+
if count == 0:
|
|
464
|
+
if as_json:
|
|
465
|
+
_print_json({
|
|
466
|
+
"provider": provider, "project": project_path,
|
|
467
|
+
"error": "no sessions found", "prompts": 0, "days": [],
|
|
468
|
+
})
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
print(f"❌ No {provider} sessions found for: {project_path}")
|
|
471
|
+
projects = PROVIDERS[provider]["list"]()
|
|
472
|
+
if projects:
|
|
473
|
+
print(f"\n📁 Available {provider} projects ({PROVIDERS[provider]['location']}):")
|
|
474
|
+
for p in projects:
|
|
475
|
+
print(f" {p}")
|
|
476
|
+
sys.exit(1)
|
|
477
|
+
|
|
478
|
+
if not timestamps:
|
|
479
|
+
if as_json:
|
|
480
|
+
_print_json({
|
|
481
|
+
"provider": provider, "project": project_path, "label": label,
|
|
482
|
+
"session_files": count, "since": _iso(since), "until": _iso(until),
|
|
483
|
+
"prompts": 0, "days": [],
|
|
484
|
+
})
|
|
485
|
+
sys.exit(0)
|
|
486
|
+
print("No user prompts found in the selected range.")
|
|
487
|
+
sys.exit(0)
|
|
488
|
+
|
|
489
|
+
daily, sessions = compute_active_hours(timestamps, idle_threshold)
|
|
490
|
+
|
|
491
|
+
total_minutes = sum(daily.values())
|
|
492
|
+
first_ts = timestamps[0]
|
|
493
|
+
last_ts = timestamps[-1]
|
|
494
|
+
span_days = (last_ts.date() - first_ts.date()).days + 1
|
|
495
|
+
|
|
496
|
+
prompts_per_day = defaultdict(int)
|
|
497
|
+
for ts in timestamps:
|
|
498
|
+
prompts_per_day[ts.date()] += 1
|
|
499
|
+
|
|
500
|
+
if as_json:
|
|
501
|
+
_print_json({
|
|
502
|
+
"provider": provider,
|
|
503
|
+
"project": project_path,
|
|
504
|
+
"label": label,
|
|
505
|
+
"session_files": count,
|
|
506
|
+
"idle_threshold_min": idle_threshold,
|
|
507
|
+
"since": _iso(since),
|
|
508
|
+
"until": _iso(until),
|
|
509
|
+
"first": _iso(first_ts),
|
|
510
|
+
"last": _iso(last_ts),
|
|
511
|
+
"span_days": span_days,
|
|
512
|
+
"prompts": len(timestamps),
|
|
513
|
+
"sessions": len(sessions),
|
|
514
|
+
"active_days": len(daily),
|
|
515
|
+
"total_minutes": round(total_minutes, 2),
|
|
516
|
+
"days": [
|
|
517
|
+
{"date": str(day),
|
|
518
|
+
"minutes": round(daily[day], 2),
|
|
519
|
+
"prompts": prompts_per_day[day]}
|
|
520
|
+
for day in sorted(daily)
|
|
521
|
+
],
|
|
522
|
+
})
|
|
523
|
+
return
|
|
524
|
+
|
|
525
|
+
print(f"✅ Found {count} session file(s) for: {label}\n")
|
|
526
|
+
|
|
527
|
+
print("=" * 52)
|
|
528
|
+
print(f" CLOCKWORK — {_provider_label(provider)} SESSION ANALYSIS")
|
|
529
|
+
print("=" * 52)
|
|
530
|
+
print(f" Project : {project_path}")
|
|
531
|
+
print(f" First : {first_ts.strftime('%Y-%m-%d %H:%M')}")
|
|
532
|
+
print(f" Last : {last_ts.strftime('%Y-%m-%d %H:%M')}")
|
|
533
|
+
print(f" Span : {span_days} calendar days")
|
|
534
|
+
print(f" Prompts : {len(timestamps)} total")
|
|
535
|
+
print(f" Sessions: {len(sessions)} (idle threshold: {idle_threshold} min)")
|
|
536
|
+
print(f" Active : {len(daily)} days")
|
|
537
|
+
print(f" Total : {fmt_duration(total_minutes)}")
|
|
538
|
+
print("=" * 52)
|
|
539
|
+
|
|
540
|
+
print(f"\n{'DATE':<14} {'ACTIVE TIME':<14} {'PROMPTS':<10} BAR")
|
|
541
|
+
print("-" * 52)
|
|
542
|
+
|
|
543
|
+
max_mins = max(daily.values())
|
|
544
|
+
for day in sorted(daily):
|
|
545
|
+
mins = daily[day]
|
|
546
|
+
bar_len = int((mins / max_mins) * 20) if max_mins else 0
|
|
547
|
+
bar = "█" * bar_len
|
|
548
|
+
print(f" {str(day):<12} {fmt_duration(mins):<14} {prompts_per_day[day]:<10} {bar}")
|
|
549
|
+
|
|
550
|
+
print("-" * 52)
|
|
551
|
+
print(f" {'TOTAL':<12} {fmt_duration(total_minutes):<14} {len(timestamps)}")
|
|
552
|
+
print()
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def analyze_all(provider, idle_threshold, since, until, as_json):
|
|
556
|
+
projects = PROVIDERS[provider]["all"]()
|
|
557
|
+
|
|
558
|
+
rows = []
|
|
559
|
+
grand_minutes = 0.0
|
|
560
|
+
grand_prompts = 0
|
|
561
|
+
grand_sessions = 0
|
|
562
|
+
|
|
563
|
+
for label, timestamps in projects:
|
|
564
|
+
timestamps = _filter_timestamps(timestamps, since, until)
|
|
565
|
+
if not timestamps:
|
|
566
|
+
continue
|
|
567
|
+
daily, sessions = compute_active_hours(timestamps, idle_threshold)
|
|
568
|
+
total_minutes = sum(daily.values())
|
|
569
|
+
rows.append({
|
|
570
|
+
"label": label,
|
|
571
|
+
"minutes": total_minutes,
|
|
572
|
+
"prompts": len(timestamps),
|
|
573
|
+
"sessions": len(sessions),
|
|
574
|
+
})
|
|
575
|
+
grand_minutes += total_minutes
|
|
576
|
+
grand_prompts += len(timestamps)
|
|
577
|
+
grand_sessions += len(sessions)
|
|
578
|
+
|
|
579
|
+
if not rows:
|
|
580
|
+
if as_json:
|
|
581
|
+
_print_json({
|
|
582
|
+
"provider": provider, "since": _iso(since), "until": _iso(until),
|
|
583
|
+
"projects": [],
|
|
584
|
+
"totals": {"projects": 0, "minutes": 0, "prompts": 0, "sessions": 0},
|
|
585
|
+
})
|
|
586
|
+
sys.exit(1)
|
|
587
|
+
print(f"❌ No {provider} projects with activity found ({PROVIDERS[provider]['location']})")
|
|
588
|
+
sys.exit(1)
|
|
589
|
+
|
|
590
|
+
rows.sort(key=lambda r: r["minutes"], reverse=True)
|
|
591
|
+
|
|
592
|
+
if as_json:
|
|
593
|
+
_print_json({
|
|
594
|
+
"provider": provider,
|
|
595
|
+
"idle_threshold_min": idle_threshold,
|
|
596
|
+
"since": _iso(since),
|
|
597
|
+
"until": _iso(until),
|
|
598
|
+
"projects": [
|
|
599
|
+
{"project": r["label"],
|
|
600
|
+
"minutes": round(r["minutes"], 2),
|
|
601
|
+
"prompts": r["prompts"],
|
|
602
|
+
"sessions": r["sessions"]}
|
|
603
|
+
for r in rows
|
|
604
|
+
],
|
|
605
|
+
"totals": {
|
|
606
|
+
"projects": len(rows),
|
|
607
|
+
"minutes": round(grand_minutes, 2),
|
|
608
|
+
"prompts": grand_prompts,
|
|
609
|
+
"sessions": grand_sessions,
|
|
610
|
+
},
|
|
611
|
+
})
|
|
612
|
+
return
|
|
613
|
+
|
|
614
|
+
max_mins = max(r["minutes"] for r in rows)
|
|
615
|
+
|
|
616
|
+
print("=" * 78)
|
|
617
|
+
print(f" CLOCKWORK — ALL {_provider_label(provider)} PROJECTS (idle threshold: {idle_threshold} min)")
|
|
618
|
+
print("=" * 78)
|
|
619
|
+
print(f" Projects: {len(rows)} with activity")
|
|
620
|
+
print(f" Prompts : {grand_prompts} total")
|
|
621
|
+
print(f" Sessions: {grand_sessions} total")
|
|
622
|
+
print(f" Total : {fmt_duration(grand_minutes)}")
|
|
623
|
+
print("=" * 78)
|
|
624
|
+
print()
|
|
625
|
+
print(f" {'PROJECT':<40} {'TIME':>9} {'PROMPTS':>8} {'SESS':>5} BAR")
|
|
626
|
+
print("-" * 78)
|
|
627
|
+
|
|
628
|
+
for r in rows:
|
|
629
|
+
name = r["label"]
|
|
630
|
+
if len(name) > 39:
|
|
631
|
+
name = "…" + name[-38:]
|
|
632
|
+
bar_len = int((r["minutes"] / max_mins) * 12) if max_mins else 0
|
|
633
|
+
bar = "█" * bar_len
|
|
634
|
+
print(f" {name:<40} {fmt_duration(r['minutes']):>9} {r['prompts']:>8} {r['sessions']:>5} {bar}")
|
|
635
|
+
|
|
636
|
+
print("-" * 78)
|
|
637
|
+
print(f" {'TOTAL':<40} {fmt_duration(grand_minutes):>9} {grand_prompts:>8} {grand_sessions:>5}")
|
|
638
|
+
print()
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _period_window(period):
|
|
642
|
+
"""Return (since, until, title) for a named period, in local time.
|
|
643
|
+
|
|
644
|
+
`today` spans local midnight to now; `week` is a rolling 7-day window
|
|
645
|
+
ending today. Boundaries are local-time-aware so "today" means the user's
|
|
646
|
+
calendar day, not UTC's.
|
|
647
|
+
"""
|
|
648
|
+
now = datetime.now().astimezone()
|
|
649
|
+
start_today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
650
|
+
if period == "today":
|
|
651
|
+
return start_today, now, "TODAY"
|
|
652
|
+
return start_today - timedelta(days=6), now, "THIS WEEK (last 7 days)"
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def analyze_summary(provider, period, idle_threshold, as_json):
|
|
656
|
+
since, until, title = _period_window(period)
|
|
657
|
+
projects = PROVIDERS[provider]["all"]()
|
|
658
|
+
|
|
659
|
+
rows = []
|
|
660
|
+
combined_daily = defaultdict(float)
|
|
661
|
+
prompts_per_day = defaultdict(int)
|
|
662
|
+
grand_minutes = 0.0
|
|
663
|
+
grand_prompts = 0
|
|
664
|
+
grand_sessions = 0
|
|
665
|
+
|
|
666
|
+
for label, timestamps in projects:
|
|
667
|
+
timestamps = _filter_timestamps(timestamps, since, until)
|
|
668
|
+
if not timestamps:
|
|
669
|
+
continue
|
|
670
|
+
daily, sessions = compute_active_hours(timestamps, idle_threshold)
|
|
671
|
+
rows.append({
|
|
672
|
+
"label": label,
|
|
673
|
+
"minutes": sum(daily.values()),
|
|
674
|
+
"prompts": len(timestamps),
|
|
675
|
+
"sessions": len(sessions),
|
|
676
|
+
})
|
|
677
|
+
for day, mins in daily.items():
|
|
678
|
+
combined_daily[day] += mins
|
|
679
|
+
for ts in timestamps:
|
|
680
|
+
prompts_per_day[ts.date()] += 1
|
|
681
|
+
grand_minutes += sum(daily.values())
|
|
682
|
+
grand_prompts += len(timestamps)
|
|
683
|
+
grand_sessions += len(sessions)
|
|
684
|
+
|
|
685
|
+
rows.sort(key=lambda r: r["minutes"], reverse=True)
|
|
686
|
+
|
|
687
|
+
if as_json:
|
|
688
|
+
_print_json({
|
|
689
|
+
"provider": provider,
|
|
690
|
+
"period": period,
|
|
691
|
+
"idle_threshold_min": idle_threshold,
|
|
692
|
+
"since": _iso(since),
|
|
693
|
+
"until": _iso(until),
|
|
694
|
+
"projects": [
|
|
695
|
+
{"project": r["label"],
|
|
696
|
+
"minutes": round(r["minutes"], 2),
|
|
697
|
+
"prompts": r["prompts"],
|
|
698
|
+
"sessions": r["sessions"]}
|
|
699
|
+
for r in rows
|
|
700
|
+
],
|
|
701
|
+
"days": [
|
|
702
|
+
{"date": str(day),
|
|
703
|
+
"minutes": round(combined_daily[day], 2),
|
|
704
|
+
"prompts": prompts_per_day[day]}
|
|
705
|
+
for day in sorted(combined_daily)
|
|
706
|
+
],
|
|
707
|
+
"totals": {
|
|
708
|
+
"projects": len(rows),
|
|
709
|
+
"minutes": round(grand_minutes, 2),
|
|
710
|
+
"prompts": grand_prompts,
|
|
711
|
+
"sessions": grand_sessions,
|
|
712
|
+
},
|
|
713
|
+
})
|
|
714
|
+
return
|
|
715
|
+
|
|
716
|
+
width = 60
|
|
717
|
+
print("=" * width)
|
|
718
|
+
print(f" CLOCKWORK — {_provider_label(provider)} · {title}")
|
|
719
|
+
print("=" * width)
|
|
720
|
+
if not rows:
|
|
721
|
+
print(" No activity yet. Time to write a prompt.")
|
|
722
|
+
print("=" * width)
|
|
723
|
+
print()
|
|
724
|
+
return
|
|
725
|
+
print(f" Active : {fmt_duration(grand_minutes)} across {len(rows)} project(s)")
|
|
726
|
+
print(f" Prompts : {grand_prompts} Sessions: {grand_sessions}")
|
|
727
|
+
print("=" * width)
|
|
728
|
+
print()
|
|
729
|
+
|
|
730
|
+
print(f" {'PROJECT':<34} {'TIME':>9} {'PROMPTS':>7} BAR")
|
|
731
|
+
print("-" * width)
|
|
732
|
+
max_mins = max(r["minutes"] for r in rows)
|
|
733
|
+
for r in rows:
|
|
734
|
+
name = r["label"]
|
|
735
|
+
if len(name) > 33:
|
|
736
|
+
name = "…" + name[-32:]
|
|
737
|
+
bar = "█" * (int((r["minutes"] / max_mins) * 10) if max_mins else 0)
|
|
738
|
+
print(f" {name:<34} {fmt_duration(r['minutes']):>9} {r['prompts']:>7} {bar}")
|
|
739
|
+
print("-" * width)
|
|
740
|
+
print(f" {'TOTAL':<34} {fmt_duration(grand_minutes):>9} {grand_prompts:>7}")
|
|
741
|
+
|
|
742
|
+
if period == "week":
|
|
743
|
+
print()
|
|
744
|
+
print(f" {'DAY':<12} {'TIME':>9} {'PROMPTS':>7} BAR")
|
|
745
|
+
print("-" * width)
|
|
746
|
+
max_day = max(combined_daily.values())
|
|
747
|
+
for day in sorted(combined_daily):
|
|
748
|
+
mins = combined_daily[day]
|
|
749
|
+
bar = "█" * (int((mins / max_day) * 10) if max_day else 0)
|
|
750
|
+
print(f" {str(day):<12} {fmt_duration(mins):>9} {prompts_per_day[day]:>7} {bar}")
|
|
751
|
+
print()
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def export_data(provider, idle_threshold, since, until, detail, anonymize):
|
|
755
|
+
"""Emit one versioned JSON bundle of every project for external tools.
|
|
756
|
+
|
|
757
|
+
Always writes JSON to stdout. `detail` selects granularity:
|
|
758
|
+
daily → per-day minutes/prompts only (smallest, least revealing)
|
|
759
|
+
sessions → adds grouped sessions with start/end + prompt counts
|
|
760
|
+
raw → adds every prompt's epoch-second timestamp (fully re-analyzable)
|
|
761
|
+
|
|
762
|
+
Higher levels are supersets: a `raw` export still carries the daily and
|
|
763
|
+
session aggregates so a consumer can start simple and drill down.
|
|
764
|
+
Timestamps are UTC-based epoch seconds; the `daily` buckets use UTC dates.
|
|
765
|
+
"""
|
|
766
|
+
projects = PROVIDERS[provider]["all"]()
|
|
767
|
+
|
|
768
|
+
out_projects = []
|
|
769
|
+
grand_minutes = 0.0
|
|
770
|
+
grand_prompts = 0
|
|
771
|
+
grand_sessions = 0
|
|
772
|
+
|
|
773
|
+
for i, (label, timestamps) in enumerate(
|
|
774
|
+
sorted(projects, key=lambda p: p[0]), start=1):
|
|
775
|
+
timestamps = _filter_timestamps(timestamps, since, until)
|
|
776
|
+
if not timestamps:
|
|
777
|
+
continue
|
|
778
|
+
daily, _ = compute_active_hours(timestamps, idle_threshold)
|
|
779
|
+
sessions = group_sessions(timestamps, idle_threshold)
|
|
780
|
+
total_minutes = sum(daily.values())
|
|
781
|
+
|
|
782
|
+
prompts_per_day = defaultdict(int)
|
|
783
|
+
for ts in timestamps:
|
|
784
|
+
prompts_per_day[ts.date()] += 1
|
|
785
|
+
|
|
786
|
+
proj = {
|
|
787
|
+
"id": _project_id(label),
|
|
788
|
+
"name": f"project-{i}" if anonymize else _display_name(label),
|
|
789
|
+
"totals": {
|
|
790
|
+
"minutes": round(total_minutes, 2),
|
|
791
|
+
"prompts": len(timestamps),
|
|
792
|
+
"sessions": len(sessions),
|
|
793
|
+
"active_days": len(daily),
|
|
794
|
+
"first": int(timestamps[0].timestamp()),
|
|
795
|
+
"last": int(timestamps[-1].timestamp()),
|
|
796
|
+
},
|
|
797
|
+
"daily": [
|
|
798
|
+
{"date": str(day),
|
|
799
|
+
"minutes": round(daily[day], 2),
|
|
800
|
+
"prompts": prompts_per_day[day]}
|
|
801
|
+
for day in sorted(daily)
|
|
802
|
+
],
|
|
803
|
+
}
|
|
804
|
+
if not anonymize:
|
|
805
|
+
proj["path"] = label
|
|
806
|
+
if detail in ("sessions", "raw"):
|
|
807
|
+
proj["sessions"] = [
|
|
808
|
+
{"start": int(s.timestamp()),
|
|
809
|
+
"end": int(e.timestamp()),
|
|
810
|
+
"minutes": round((e - s).total_seconds() / 60, 2),
|
|
811
|
+
"prompts": c}
|
|
812
|
+
for s, e, c in sessions
|
|
813
|
+
]
|
|
814
|
+
if detail == "raw":
|
|
815
|
+
proj["prompts"] = [int(ts.timestamp()) for ts in timestamps]
|
|
816
|
+
|
|
817
|
+
out_projects.append(proj)
|
|
818
|
+
grand_minutes += total_minutes
|
|
819
|
+
grand_prompts += len(timestamps)
|
|
820
|
+
grand_sessions += len(sessions)
|
|
821
|
+
|
|
822
|
+
_print_json({
|
|
823
|
+
"schema": "clockwork/v1",
|
|
824
|
+
"generated_at": datetime.now().astimezone().isoformat(),
|
|
825
|
+
"provider": provider,
|
|
826
|
+
"idle_threshold_min": idle_threshold,
|
|
827
|
+
"detail": detail,
|
|
828
|
+
"anonymized": anonymize,
|
|
829
|
+
"daily_tz": "UTC",
|
|
830
|
+
"since": _iso(since),
|
|
831
|
+
"until": _iso(until),
|
|
832
|
+
"projects": out_projects,
|
|
833
|
+
"totals": {
|
|
834
|
+
"projects": len(out_projects),
|
|
835
|
+
"minutes": round(grand_minutes, 2),
|
|
836
|
+
"prompts": grand_prompts,
|
|
837
|
+
"sessions": grand_sessions,
|
|
838
|
+
},
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def list_projects(provider, as_json):
|
|
843
|
+
projects = PROVIDERS[provider]["list"]()
|
|
844
|
+
if as_json:
|
|
845
|
+
_print_json({
|
|
846
|
+
"provider": provider,
|
|
847
|
+
"location": PROVIDERS[provider]["location"],
|
|
848
|
+
"projects": projects,
|
|
849
|
+
})
|
|
850
|
+
sys.exit(0 if projects else 1)
|
|
851
|
+
if not projects:
|
|
852
|
+
print(f"❌ No {provider} projects found ({PROVIDERS[provider]['location']})")
|
|
853
|
+
sys.exit(1)
|
|
854
|
+
print(f"📁 {len(projects)} {provider} project(s) ({PROVIDERS[provider]['location']}):\n")
|
|
855
|
+
for p in projects:
|
|
856
|
+
print(f" {p}")
|
|
857
|
+
print()
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _die(msg):
|
|
861
|
+
print(f"clockwork: {msg}", file=sys.stderr)
|
|
862
|
+
sys.exit(2)
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def _parse_args(argv):
|
|
866
|
+
"""Split flags from positionals. Returns (positionals, opts)."""
|
|
867
|
+
opts = {"json": False, "since": None, "until": None,
|
|
868
|
+
"detail": "raw", "anonymize": False}
|
|
869
|
+
positionals = []
|
|
870
|
+
i = 0
|
|
871
|
+
while i < len(argv):
|
|
872
|
+
a = argv[i]
|
|
873
|
+
if a == "--json":
|
|
874
|
+
opts["json"] = True
|
|
875
|
+
elif a == "--anonymize":
|
|
876
|
+
opts["anonymize"] = True
|
|
877
|
+
elif a in ("--since", "--until", "--detail"):
|
|
878
|
+
i += 1
|
|
879
|
+
if i >= len(argv):
|
|
880
|
+
_die(f"{a} requires a value")
|
|
881
|
+
opts[a[2:]] = argv[i]
|
|
882
|
+
elif a.startswith("--since="):
|
|
883
|
+
opts["since"] = a.split("=", 1)[1]
|
|
884
|
+
elif a.startswith("--until="):
|
|
885
|
+
opts["until"] = a.split("=", 1)[1]
|
|
886
|
+
elif a.startswith("--detail="):
|
|
887
|
+
opts["detail"] = a.split("=", 1)[1]
|
|
888
|
+
elif a.startswith("--"):
|
|
889
|
+
_die(f"unknown option: {a}")
|
|
890
|
+
else:
|
|
891
|
+
positionals.append(a)
|
|
892
|
+
i += 1
|
|
893
|
+
return positionals, opts
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def main():
|
|
897
|
+
positionals, opts = _parse_args(sys.argv[1:])
|
|
898
|
+
if len(positionals) < 2:
|
|
899
|
+
print(__doc__.strip())
|
|
900
|
+
sys.exit(1)
|
|
901
|
+
|
|
902
|
+
provider = positionals[0].lower()
|
|
903
|
+
if provider not in PROVIDERS:
|
|
904
|
+
print(f"Unknown provider: {positionals[0]!r} (expected 'claude', 'codex', or 'both')\n")
|
|
905
|
+
print(__doc__.strip())
|
|
906
|
+
sys.exit(1)
|
|
907
|
+
|
|
908
|
+
target = positionals[1]
|
|
909
|
+
idle_threshold = int(positionals[2]) if len(positionals) > 2 else 30
|
|
910
|
+
|
|
911
|
+
if opts["detail"] not in ("raw", "sessions", "daily"):
|
|
912
|
+
_die(f"--detail must be raw, sessions, or daily (got {opts['detail']!r})")
|
|
913
|
+
|
|
914
|
+
try:
|
|
915
|
+
since = _parse_date_bound(opts["since"])
|
|
916
|
+
until = _parse_date_bound(opts["until"], end_of_day=True)
|
|
917
|
+
except ValueError as e:
|
|
918
|
+
_die(str(e))
|
|
919
|
+
|
|
920
|
+
if target == "all":
|
|
921
|
+
analyze_all(provider, idle_threshold, since, until, opts["json"])
|
|
922
|
+
elif target in ("today", "week"):
|
|
923
|
+
analyze_summary(provider, target, idle_threshold, opts["json"])
|
|
924
|
+
elif target == "export":
|
|
925
|
+
export_data(provider, idle_threshold, since, until,
|
|
926
|
+
opts["detail"], opts["anonymize"])
|
|
927
|
+
elif target == "list":
|
|
928
|
+
list_projects(provider, opts["json"])
|
|
929
|
+
else:
|
|
930
|
+
analyze_single(provider, target, idle_threshold, since, until, opts["json"])
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clockwork-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Mine your Claude Code and Codex session logs to estimate active time
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/danielsich/clockwork
|
|
7
|
+
Project-URL: Repository, https://github.com/danielsich/clockwork
|
|
8
|
+
Keywords: claude,codex,productivity,time-tracking,cli
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.7
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# clockwork
|
|
20
|
+
|
|
21
|
+
A tiny, dependency-free CLI that mines your **Claude Code** and **Codex**
|
|
22
|
+
session logs to estimate how much *active* time you've actually spent — per
|
|
23
|
+
project, per day — and renders it as ASCII bar charts in your terminal.
|
|
24
|
+
|
|
25
|
+
No config, no database, no telemetry. Just Python 3 and the log files those
|
|
26
|
+
tools already write to your machine.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
====================================================
|
|
30
|
+
CLOCKWORK — CLAUDE SESSION ANALYSIS
|
|
31
|
+
====================================================
|
|
32
|
+
Project : ~/dev/clockwork
|
|
33
|
+
First : 2026-06-28 09:12
|
|
34
|
+
Last : 2026-07-05 23:34
|
|
35
|
+
Span : 8 calendar days
|
|
36
|
+
Prompts : 214 total
|
|
37
|
+
Sessions: 19 (idle threshold: 30 min)
|
|
38
|
+
Active : 6 days
|
|
39
|
+
Total : 11h 42m
|
|
40
|
+
====================================================
|
|
41
|
+
|
|
42
|
+
DATE ACTIVE TIME PROMPTS BAR
|
|
43
|
+
----------------------------------------------------
|
|
44
|
+
2026-06-28 2h 05m 38 ████████████████████
|
|
45
|
+
2026-06-29 1h 12m 21 ███████████
|
|
46
|
+
2026-07-01 0h 48m 14 ███████
|
|
47
|
+
...
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## How it works
|
|
51
|
+
|
|
52
|
+
`clockwork` reads the JSONL transcripts each tool writes, extracts the
|
|
53
|
+
timestamps of your **prompts** (not the assistant's replies), and groups them
|
|
54
|
+
into sessions. Any gap longer than the *idle threshold* (default 30 minutes)
|
|
55
|
+
starts a new session. A session's duration is the time from its first to its
|
|
56
|
+
last prompt, split across calendar-day boundaries so a session that crosses
|
|
57
|
+
midnight is credited to each day it spans.
|
|
58
|
+
|
|
59
|
+
**Data sources**
|
|
60
|
+
|
|
61
|
+
| Provider | Location |
|
|
62
|
+
| -------- | -------- |
|
|
63
|
+
| `claude` | `~/.claude/projects/<encoded-path>/*.jsonl` (macOS/Linux)<br>`%APPDATA%\Claude\projects\<encoded-path>\*.jsonl` (Windows) |
|
|
64
|
+
| `codex` | `$CODEX_HOME/sessions/**/rollout-*.jsonl` (default `~/.codex/sessions`) |
|
|
65
|
+
| `both` | merges the two sources per project |
|
|
66
|
+
|
|
67
|
+
> **Note:** "active time" is a proxy. It measures time *within* sessions, so
|
|
68
|
+
> thinking and response time between prompts counts as active, and a session
|
|
69
|
+
> with a single prompt is floored to one minute. Treat the totals as a
|
|
70
|
+
> reasonable estimate, not a stopwatch.
|
|
71
|
+
|
|
72
|
+
> **`both` and path matching:** `both` combines Claude and Codex prompts by
|
|
73
|
+
> project path. Single-project mode (`clockwork both ~/dev/x`) is exact,
|
|
74
|
+
> because it encodes the path you pass. In `all` / `export` / `list`, projects
|
|
75
|
+
> are matched by their displayed path — and Claude stores paths dash-encoded,
|
|
76
|
+
> so one containing spaces or dashes can't always be reversed to match Codex's
|
|
77
|
+
> real path. Such a project may appear as two rows rather than merging.
|
|
78
|
+
|
|
79
|
+
## Requirements
|
|
80
|
+
|
|
81
|
+
- Python 3.7+ (standard library only — no `pip install` needed)
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
**macOS / Linux**
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
git clone https://github.com/danielsich/clockwork.git
|
|
89
|
+
cd clockwork
|
|
90
|
+
chmod +x clockwork
|
|
91
|
+
|
|
92
|
+
# option A: symlink into a directory already on your PATH
|
|
93
|
+
ln -s "$PWD/clockwork" ~/.local/bin/clockwork
|
|
94
|
+
|
|
95
|
+
# option B: just run it directly
|
|
96
|
+
./clockwork claude all
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
**Windows**
|
|
100
|
+
|
|
101
|
+
```powershell
|
|
102
|
+
git clone https://github.com/danielsich/clockwork.git
|
|
103
|
+
cd clockwork
|
|
104
|
+
|
|
105
|
+
# Run directly with Python (the shebang line is ignored on Windows)
|
|
106
|
+
python clockwork claude all
|
|
107
|
+
|
|
108
|
+
# Or add the directory to your PATH and invoke as:
|
|
109
|
+
python -m clockwork claude all # not needed — just call the script
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> clockwork looks for Claude logs in `%APPDATA%\Claude\projects` first, then
|
|
113
|
+
> `~\.claude\projects`. One of those will already exist if you have Claude Code
|
|
114
|
+
> installed.
|
|
115
|
+
|
|
116
|
+
## Usage
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
clockwork <provider> <project-path> [idle-min] [options] Analyze one project
|
|
120
|
+
clockwork <provider> all [idle-min] [options] Rank all projects
|
|
121
|
+
clockwork <provider> today [idle-min] [options] Today, all projects
|
|
122
|
+
clockwork <provider> week [idle-min] [options] Last 7 days, all projects
|
|
123
|
+
clockwork <provider> export [idle-min] [options] Bundle all projects as JSON
|
|
124
|
+
clockwork <provider> list [options] List project folders
|
|
125
|
+
|
|
126
|
+
<provider> is one of: claude | codex | both
|
|
127
|
+
|
|
128
|
+
Options:
|
|
129
|
+
--json Emit machine-readable JSON instead of ASCII tables
|
|
130
|
+
--since <when> Only count prompts on/after this point in time
|
|
131
|
+
--until <when> Only count prompts on/before this point in time
|
|
132
|
+
<when> is YYYY-MM-DD, an ISO timestamp, or a relative
|
|
133
|
+
form like 7d (7 days ago) or 2w (2 weeks ago)
|
|
134
|
+
--detail <level> export granularity: raw | sessions | daily (default raw)
|
|
135
|
+
--anonymize export: replace project paths with a hash id + generic name
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Examples
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
# Time spent on a single project (Claude Code)
|
|
142
|
+
clockwork claude ~/dev/myproject
|
|
143
|
+
|
|
144
|
+
# Same, but treat gaps under 45 min as the same session (Codex)
|
|
145
|
+
clockwork codex ~/dev/myproject 45
|
|
146
|
+
|
|
147
|
+
# Both tools at once — combined time on one project, or ranked across all
|
|
148
|
+
clockwork both ~/dev/myproject
|
|
149
|
+
clockwork both today
|
|
150
|
+
|
|
151
|
+
# Rank every project you've worked on, most time first
|
|
152
|
+
clockwork codex all
|
|
153
|
+
|
|
154
|
+
# List the projects clockwork can see
|
|
155
|
+
clockwork claude list
|
|
156
|
+
|
|
157
|
+
# Daily check-in: everything you did today, across all projects
|
|
158
|
+
clockwork claude today
|
|
159
|
+
|
|
160
|
+
# Your week at a glance (per-project + a day-by-day breakdown)
|
|
161
|
+
clockwork codex week
|
|
162
|
+
|
|
163
|
+
# Only the last 7 days, across all projects
|
|
164
|
+
clockwork claude all --since 7d
|
|
165
|
+
|
|
166
|
+
# A specific date range for one project
|
|
167
|
+
clockwork claude ~/dev/myproject --since 2026-07-01 --until 2026-07-05
|
|
168
|
+
|
|
169
|
+
# Machine-readable output — pipe into jq, a spreadsheet, or a dashboard
|
|
170
|
+
clockwork codex all --json | jq '.projects[] | {project, minutes}'
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The optional trailing number overrides the idle threshold in minutes
|
|
174
|
+
(default `30`). A larger threshold merges short breaks into one session; a
|
|
175
|
+
smaller one splits work into more, shorter sessions.
|
|
176
|
+
|
|
177
|
+
### Daily & weekly summaries
|
|
178
|
+
|
|
179
|
+
`today` and `week` aggregate **every** project into a single check-in instead
|
|
180
|
+
of analyzing one at a time. `today` covers local midnight to now; `week` is a
|
|
181
|
+
rolling 7-day window ending today. Both show a per-project breakdown, and
|
|
182
|
+
`week` adds a day-by-day view so you can see your week at a glance. Day
|
|
183
|
+
boundaries are **local time**, so "today" means your calendar day, not UTC's.
|
|
184
|
+
They honor the optional `idle-min` and pair with `--json`.
|
|
185
|
+
|
|
186
|
+
### Filtering by date
|
|
187
|
+
|
|
188
|
+
`--since` and `--until` restrict which prompts are counted. Each accepts a
|
|
189
|
+
bare date (`2026-07-01`), a full ISO timestamp, or a relative form — `7d`
|
|
190
|
+
(7 days ago) or `2w` (2 weeks ago). A bare `--until` date is inclusive of the
|
|
191
|
+
whole day. This works in every mode (`<project>`, `all`, and `list`-adjacent
|
|
192
|
+
analysis).
|
|
193
|
+
|
|
194
|
+
### JSON output
|
|
195
|
+
|
|
196
|
+
`--json` swaps the ASCII tables for structured JSON on stdout (errors and
|
|
197
|
+
usage still go to stderr), so clockwork composes with `jq`, cron jobs, or any
|
|
198
|
+
dashboard. Exit codes are script-friendly: `0` on success, `1` when nothing
|
|
199
|
+
matched, `2` for bad arguments.
|
|
200
|
+
|
|
201
|
+
## Exporting for external tools
|
|
202
|
+
|
|
203
|
+
`clockwork <provider> export` writes a single **self-describing, versioned**
|
|
204
|
+
JSON bundle covering every project at once — the format a companion web app or
|
|
205
|
+
dashboard would ingest. Unlike `--json` on the analysis commands (which emits
|
|
206
|
+
one pre-aggregated view), the export is designed to be re-analyzed downstream.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# Full-fidelity export you can re-analyze anywhere
|
|
210
|
+
clockwork claude export > clockwork.json
|
|
211
|
+
|
|
212
|
+
# Smaller / shareable: grouped sessions only, with paths stripped
|
|
213
|
+
clockwork codex export --detail sessions --anonymize > share.json
|
|
214
|
+
|
|
215
|
+
# Export honors --since/--until too
|
|
216
|
+
clockwork claude export --since 30d > last-month.json
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Detail levels** (`--detail`, default `raw`). Each level is a superset of the
|
|
220
|
+
one below, so a consumer can start with the aggregates and drill down:
|
|
221
|
+
|
|
222
|
+
| Level | Adds | Lets a consumer… |
|
|
223
|
+
| ----- | ---- | ---------------- |
|
|
224
|
+
| `daily` | per-day `minutes` / `prompts` | draw timelines and totals |
|
|
225
|
+
| `sessions` | grouped `sessions` (start/end + prompt count) | re-bucket by timezone / date range |
|
|
226
|
+
| `raw` | every prompt as an epoch-second timestamp | re-apply **any** idle threshold, build hour-of-day heatmaps, streaks — anything |
|
|
227
|
+
|
|
228
|
+
**Privacy.** Paths are included by default (it's your own data). `--anonymize`
|
|
229
|
+
drops the `path`, renames projects to `project-N`, and keeps only a stable
|
|
230
|
+
hash `id` — so an uploaded file leaks nothing identifying while still letting a
|
|
231
|
+
tool tell projects apart across exports.
|
|
232
|
+
|
|
233
|
+
### Export schema (`clockwork/v1`)
|
|
234
|
+
|
|
235
|
+
```jsonc
|
|
236
|
+
{
|
|
237
|
+
"schema": "clockwork/v1",
|
|
238
|
+
"generated_at": "2026-07-06T00:30:00+02:00", // local ISO-8601
|
|
239
|
+
"provider": "claude",
|
|
240
|
+
"idle_threshold_min": 30,
|
|
241
|
+
"detail": "raw", // raw | sessions | daily
|
|
242
|
+
"anonymized": false,
|
|
243
|
+
"daily_tz": "UTC", // the "daily" buckets use UTC calendar dates
|
|
244
|
+
"since": null, // ISO bound if --since was given, else null
|
|
245
|
+
"until": null,
|
|
246
|
+
"projects": [
|
|
247
|
+
{
|
|
248
|
+
"id": "0ac6be84", // stable sha1(path) prefix; survives --anonymize
|
|
249
|
+
"name": "myproject", // basename, or "project-N" when anonymized
|
|
250
|
+
"path": "/Users/you/dev/myproject", // omitted when anonymized
|
|
251
|
+
"totals": {
|
|
252
|
+
"minutes": 1234.76, "prompts": 1646, "sessions": 27,
|
|
253
|
+
"active_days": 12,
|
|
254
|
+
"first": 1780521570, "last": 1782130426 // epoch seconds
|
|
255
|
+
},
|
|
256
|
+
"daily": [ { "date": "2026-06-03", "minutes": 4.34, "prompts": 27 } ],
|
|
257
|
+
"sessions": [ { "start": 1780521570, "end": 1780521830,
|
|
258
|
+
"minutes": 4.34, "prompts": 27 } ], // detail >= sessions
|
|
259
|
+
"prompts": [ 1780521570, 1780521582 ] // detail == raw
|
|
260
|
+
}
|
|
261
|
+
],
|
|
262
|
+
"totals": { "projects": 6, "minutes": 3392.97, "prompts": 4588, "sessions": 64 }
|
|
263
|
+
}
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
All instants are **UTC-based epoch seconds** (`new Date(sec * 1000)` in JS), so
|
|
267
|
+
the consumer picks the display timezone. The `schema` field is the version
|
|
268
|
+
contract — bump it if the shape ever changes so tools can guard on it.
|
|
269
|
+
|
|
270
|
+
## License
|
|
271
|
+
|
|
272
|
+
[MIT](LICENSE) © 2026 Daniel Sich
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
clockwork_cli/__init__.py,sha256=SPIDyK2AHF0EbHKz9bKrjqbJo0MzdDUNdcoc9v0IoNw,31795
|
|
2
|
+
clockwork_cli/__main__.py,sha256=HcXz3nL5ocmXEASzCOKbjM3Sj6czPe8sqJSXglAjBw0,39
|
|
3
|
+
clockwork_cli-0.1.0.dist-info/licenses/LICENSE,sha256=pz4uy4Ra9e-Ks0T7UFFi8rGL86-f29m2ZOB0WgixjLY,1068
|
|
4
|
+
clockwork_cli-0.1.0.dist-info/METADATA,sha256=0qj9yD53wxMpI6iPbQ9k9dQJX14lT8HxQlBU9p4eU5s,10504
|
|
5
|
+
clockwork_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
clockwork_cli-0.1.0.dist-info/entry_points.txt,sha256=wmiWdgLrHpNKK9wDwCOiX4zyLFx71_G5DzydSiu8DWs,49
|
|
7
|
+
clockwork_cli-0.1.0.dist-info/top_level.txt,sha256=g5lh3hqDChq8bNBmiN2Kze86weZTddLJspNABd-2ckg,14
|
|
8
|
+
clockwork_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniel Sich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
clockwork_cli
|