cctally 1.67.1 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +754 -109
  4. package/bin/_cctally_cache_report.py +41 -17
  5. package/bin/_cctally_codex.py +109 -4
  6. package/bin/_cctally_config.py +368 -2
  7. package/bin/_cctally_core.py +187 -15
  8. package/bin/_cctally_dashboard.py +679 -5
  9. package/bin/_cctally_dashboard_conversation.py +9 -4
  10. package/bin/_cctally_dashboard_envelope.py +110 -1
  11. package/bin/_cctally_dashboard_share.py +557 -79
  12. package/bin/_cctally_db.py +366 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +131 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +321 -92
  17. package/bin/_cctally_project.py +24 -8
  18. package/bin/_cctally_quota.py +1381 -0
  19. package/bin/_cctally_record.py +319 -14
  20. package/bin/_cctally_refresh.py +105 -3
  21. package/bin/_cctally_reporting.py +7 -1
  22. package/bin/_cctally_setup.py +343 -113
  23. package/bin/_cctally_statusline.py +228 -0
  24. package/bin/_cctally_tui.py +787 -7
  25. package/bin/_lib_alert_axes.py +11 -0
  26. package/bin/_lib_codex_hooks.py +367 -0
  27. package/bin/_lib_conversation_query.py +156 -67
  28. package/bin/_lib_doctor.py +238 -0
  29. package/bin/_lib_jsonl.py +529 -162
  30. package/bin/_lib_quota.py +566 -0
  31. package/bin/_lib_share.py +152 -34
  32. package/bin/_lib_snapshot_cache.py +26 -0
  33. package/bin/_lib_source_identity.py +74 -0
  34. package/bin/cctally +325 -10
  35. package/dashboard/static/assets/index-CBbErI-P.js +80 -0
  36. package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
  37. package/dashboard/static/dashboard.html +2 -2
  38. package/package.json +5 -1
  39. package/dashboard/static/assets/index-BybNp_Di.js +0 -80
  40. package/dashboard/static/assets/index-DgAmLK65.css +0 -1
@@ -918,13 +918,13 @@ def _resolve_cache_report_window(
918
918
  return since, until
919
919
 
920
920
 
921
- def _emit_cache_report_json(
921
+ def _cache_report_json_payload(
922
922
  rows: list["crk.CacheRow"],
923
923
  mode: str,
924
924
  *,
925
925
  now_utc: dt.datetime | None = None,
926
- ) -> str:
927
- """Serialize rows + totals to JSON matching the spec schema.
926
+ ) -> dict[str, Any]:
927
+ """Build rows + totals matching the cache-report JSON schema.
928
928
 
929
929
  Daily mode keeps the top-level `days` key and per-row `totalCost` /
930
930
  totals.totalCost aliases for backward compat. Session mode uses
@@ -1010,7 +1010,19 @@ def _emit_cache_report_json(
1010
1010
  for row_dict in output[top_key]:
1011
1011
  row_dict["totalCost"] = row_dict["cost"]
1012
1012
  output["totals"]["totalCost"] = output["totals"]["cost"]
1013
- return json.dumps(_cctally().stamp_schema_version(output), indent=2)
1013
+ return _cctally().stamp_schema_version(output)
1014
+
1015
+
1016
+ def _emit_cache_report_json(
1017
+ rows: list["crk.CacheRow"],
1018
+ mode: str,
1019
+ *,
1020
+ now_utc: dt.datetime | None = None,
1021
+ ) -> str:
1022
+ """Serialize :func:`_cache_report_json_payload` without changing bytes."""
1023
+ return json.dumps(
1024
+ _cache_report_json_payload(rows, mode, now_utc=now_utc), indent=2,
1025
+ )
1014
1026
 
1015
1027
 
1016
1028
  def _build_cache_report_title(args: argparse.Namespace, mode: str) -> str:
@@ -1094,6 +1106,7 @@ def _sort_cache_rows(
1094
1106
 
1095
1107
  def cmd_cache_report(args: argparse.Namespace) -> int:
1096
1108
  c = _cctally()
1109
+ c._share_validate_args(args)
1097
1110
  config = c._load_claude_config_for_args(args)
1098
1111
  # Session A (spec §7.2): bridge -z/--timezone into args.tz so the
1099
1112
  # existing resolve_display_tz precedence absorbs the new alias. The
@@ -1140,12 +1153,15 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
1140
1153
 
1141
1154
  if since == until:
1142
1155
  if args.json:
1143
- print(json.dumps(
1144
- c.stamp_schema_version(
1145
- {top_key: [], "totals": None,
1146
- "generatedAt": now_utc_iso(now_utc=now_utc)}),
1147
- indent=2,
1148
- ))
1156
+ payload = c.stamp_schema_version(
1157
+ {top_key: [], "totals": None,
1158
+ "generatedAt": now_utc_iso(now_utc=now_utc)}
1159
+ )
1160
+ sink = getattr(args, "_source_result_sink", None)
1161
+ if sink is not None:
1162
+ sink(payload)
1163
+ else:
1164
+ print(json.dumps(payload, indent=2))
1149
1165
  else:
1150
1166
  print("(no cache activity in window)")
1151
1167
  return 0
@@ -1163,12 +1179,15 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
1163
1179
 
1164
1180
  if not rows:
1165
1181
  if args.json:
1166
- print(json.dumps(
1167
- c.stamp_schema_version(
1168
- {top_key: [], "totals": None,
1169
- "generatedAt": now_utc_iso(now_utc=now_utc)}),
1170
- indent=2,
1171
- ))
1182
+ payload = c.stamp_schema_version(
1183
+ {top_key: [], "totals": None,
1184
+ "generatedAt": now_utc_iso(now_utc=now_utc)}
1185
+ )
1186
+ sink = getattr(args, "_source_result_sink", None)
1187
+ if sink is not None:
1188
+ sink(payload)
1189
+ else:
1190
+ print(json.dumps(payload, indent=2))
1172
1191
  else:
1173
1192
  print("(no cache activity in window)")
1174
1193
  return 0
@@ -1186,7 +1205,12 @@ def cmd_cache_report(args: argparse.Namespace) -> int:
1186
1205
  _sort_cache_rows(rows, resolved_sort, mode)
1187
1206
 
1188
1207
  if args.json:
1189
- print(_emit_cache_report_json(rows, mode, now_utc=now_utc))
1208
+ payload = _cache_report_json_payload(rows, mode, now_utc=now_utc)
1209
+ sink = getattr(args, "_source_result_sink", None)
1210
+ if sink is not None:
1211
+ sink(payload)
1212
+ else:
1213
+ print(json.dumps(payload, indent=2))
1190
1214
  return 0
1191
1215
 
1192
1216
  title = _build_cache_report_title(args, mode)
@@ -37,10 +37,14 @@ import json
37
37
  import os
38
38
  import sys
39
39
  from dataclasses import dataclass, field
40
+ from datetime import timezone
40
41
 
41
42
  from _cctally_core import WEEKDAY_MAP, _command_as_of, eprint, get_week_start_name
42
43
 
43
44
 
45
+ UTC = timezone.utc
46
+
47
+
44
48
  def _cctally():
45
49
  """Resolve the current `cctally` module at call-time (spec §3.1)."""
46
50
  return sys.modules["cctally"]
@@ -279,10 +283,84 @@ def _resolve_codex_speed(requested: str) -> str:
279
283
  return requested
280
284
 
281
285
 
286
+ def _build_codex_share_snapshot(command: str, view, rows):
287
+ """Build the four established Codex report artifacts without path fields."""
288
+ c = _cctally()
289
+ lib = c._share_load_lib()
290
+ end = getattr(view, "period_end", None) or _command_as_of()
291
+ start = getattr(view, "period_start", None) or end
292
+ if end < start:
293
+ start = end
294
+ display_tz = getattr(view, "display_tz_label", "UTC") or "UTC"
295
+ period_label = f"{start.date().isoformat()} → {end.date().isoformat()} ({display_tz})"
296
+ titles = {
297
+ "codex-daily": "Codex Token Usage — Daily",
298
+ "codex-monthly": "Codex Token Usage — Monthly",
299
+ "codex-weekly": "Codex Token Usage — Weekly",
300
+ "codex-session": "Codex Token Usage — Sessions",
301
+ }
302
+ if command not in titles:
303
+ raise ValueError(f"unknown Codex share command: {command}")
304
+ if command == "codex-session":
305
+ columns = (
306
+ lib.ColumnSpec(key="session", label="Session"),
307
+ lib.ColumnSpec(key="last", label="Last Activity"),
308
+ lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
309
+ lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
310
+ )
311
+ table_rows = tuple(
312
+ lib.Row(cells={
313
+ "session": lib.TextCell(f"Session {index + 1}"),
314
+ "last": lib.TextCell(getattr(row, "last_activity").astimezone(UTC).date().isoformat()),
315
+ "tokens": lib.TextCell(f"{int(getattr(row, 'total_tokens', 0)):,}"),
316
+ "cost": lib.MoneyCell(float(getattr(row, "cost_usd", 0.0))),
317
+ })
318
+ for index, row in enumerate(rows)
319
+ )
320
+ else:
321
+ first_label = {
322
+ "codex-daily": "Date",
323
+ "codex-monthly": "Month",
324
+ "codex-weekly": "Week",
325
+ }[command]
326
+ columns = (
327
+ lib.ColumnSpec(key="bucket", label=first_label),
328
+ lib.ColumnSpec(key="tokens", label="Tokens", align="right"),
329
+ lib.ColumnSpec(key="cost", label="$ Cost", align="right"),
330
+ )
331
+ table_rows = tuple(
332
+ lib.Row(cells={
333
+ "bucket": lib.TextCell(str(getattr(row, "bucket", "—"))),
334
+ "tokens": lib.TextCell(f"{int(getattr(row, 'total_tokens', 0)):,}"),
335
+ "cost": lib.MoneyCell(float(getattr(row, "cost_usd", 0.0))),
336
+ })
337
+ for row in rows
338
+ )
339
+ return lib.ShareSnapshot(
340
+ cmd=command,
341
+ title=titles[command],
342
+ subtitle=period_label,
343
+ period=lib.PeriodSpec(start=start, end=end, display_tz=display_tz, label=period_label),
344
+ columns=columns,
345
+ rows=table_rows,
346
+ chart=None,
347
+ totals=(
348
+ lib.Totalled(label="Total", value=f"${float(getattr(view, 'total_cost_usd', 0.0)):,.2f}"),
349
+ ),
350
+ notes=(),
351
+ generated_at=end,
352
+ version=c._share_resolve_version(),
353
+ source="codex",
354
+ source_label="Codex",
355
+ availability="empty" if not rows else "ok",
356
+ )
357
+
358
+
282
359
  def cmd_codex_daily(args: argparse.Namespace) -> int:
283
360
  """Show Codex usage report grouped by date (display tz, --tz, or --timezone)."""
284
361
  c = _cctally()
285
- config = c.load_config()
362
+ c._share_validate_args(args)
363
+ config = c.load_config(getattr(args, "config", None))
286
364
  tz_obj = c.resolve_display_tz(args, config)
287
365
  args._resolved_tz = tz_obj
288
366
  # Codex aggregators take a tz_name string. F2 fix: precedence is
@@ -312,6 +390,12 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
312
390
  if args.order == "desc":
313
391
  days = list(reversed(days))
314
392
 
393
+ if getattr(args, "format", None):
394
+ c._share_render_and_emit(
395
+ _build_codex_share_snapshot("codex-daily", view, days), args,
396
+ )
397
+ return 0
398
+
315
399
  if not days:
316
400
  # Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
317
401
  c._emit_codex_no_data(args, "daily")
@@ -347,7 +431,8 @@ def cmd_codex_daily(args: argparse.Namespace) -> int:
347
431
  def cmd_codex_monthly(args: argparse.Namespace) -> int:
348
432
  """Show Codex usage report grouped by calendar month (display tz, --tz, or --timezone)."""
349
433
  c = _cctally()
350
- config = c.load_config()
434
+ c._share_validate_args(args)
435
+ config = c.load_config(getattr(args, "config", None))
351
436
  tz_obj = c.resolve_display_tz(args, config)
352
437
  args._resolved_tz = tz_obj
353
438
  # F2 fix: see cmd_codex_daily.
@@ -371,6 +456,12 @@ def cmd_codex_monthly(args: argparse.Namespace) -> int:
371
456
  if args.order == "desc":
372
457
  months = list(reversed(months))
373
458
 
459
+ if getattr(args, "format", None):
460
+ c._share_render_and_emit(
461
+ _build_codex_share_snapshot("codex-monthly", view, months), args,
462
+ )
463
+ return 0
464
+
374
465
  if not months:
375
466
  # Match upstream's no-data sentinel (see _emit_codex_no_data docstring).
376
467
  c._emit_codex_no_data(args, "monthly")
@@ -407,7 +498,8 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
407
498
  """Show Codex usage grouped by week (display tz, --tz, or --timezone)."""
408
499
  c = _cctally()
409
500
  now_utc = _command_as_of()
410
- config = c.load_config()
501
+ c._share_validate_args(args)
502
+ config = c.load_config(getattr(args, "config", None))
411
503
  tz_obj = c.resolve_display_tz(args, config)
412
504
  args._resolved_tz = tz_obj
413
505
  # F2 fix: see cmd_codex_daily.
@@ -434,6 +526,12 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
434
526
  if args.order == "desc":
435
527
  weeks = list(reversed(weeks))
436
528
 
529
+ if getattr(args, "format", None):
530
+ c._share_render_and_emit(
531
+ _build_codex_share_snapshot("codex-weekly", view, weeks), args,
532
+ )
533
+ return 0
534
+
437
535
  if not weeks:
438
536
  # Match upstream's no-data sentinel (same string daily/monthly use).
439
537
  c._emit_codex_no_data(args, "weekly")
@@ -472,7 +570,8 @@ def cmd_codex_weekly(args: argparse.Namespace) -> int:
472
570
  def cmd_codex_session(args: argparse.Namespace) -> int:
473
571
  """Show Codex usage report grouped by session (sorted by last activity)."""
474
572
  c = _cctally()
475
- config = c.load_config()
573
+ c._share_validate_args(args)
574
+ config = c.load_config(getattr(args, "config", None))
476
575
  tz_obj = c.resolve_display_tz(args, config)
477
576
  args._resolved_tz = tz_obj
478
577
  # F2 fix: see cmd_codex_daily.
@@ -498,6 +597,12 @@ def cmd_codex_session(args: argparse.Namespace) -> int:
498
597
  if args.order == "asc":
499
598
  sessions = list(reversed(sessions))
500
599
 
600
+ if getattr(args, "format", None):
601
+ c._share_render_and_emit(
602
+ _build_codex_share_snapshot("codex-session", view, sessions), args,
603
+ )
604
+ return 0
605
+
501
606
  if not sessions:
502
607
  # Match upstream's no-data sentinel (plural "sessions" matches upstream
503
608
  # — confirmed in @ccusage/codex@18.0.8 dist/index.js around line 7962).