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.
@@ -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,3 @@
1
+ from clockwork_cli import main
2
+
3
+ main()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ clockwork = clockwork_cli:main
@@ -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