cctally 1.80.4 → 1.82.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 (53) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/bin/_cctally_account.py +397 -0
  3. package/bin/_cctally_alerts.py +58 -2
  4. package/bin/_cctally_cache.py +696 -166
  5. package/bin/_cctally_cache_report.py +21 -3
  6. package/bin/_cctally_config.py +119 -8
  7. package/bin/_cctally_core.py +343 -48
  8. package/bin/_cctally_dashboard.py +32 -7
  9. package/bin/_cctally_dashboard_conversation.py +6 -2
  10. package/bin/_cctally_dashboard_envelope.py +49 -0
  11. package/bin/_cctally_dashboard_share.py +78 -1
  12. package/bin/_cctally_dashboard_sources.py +434 -48
  13. package/bin/_cctally_db.py +659 -152
  14. package/bin/_cctally_diff.py +9 -0
  15. package/bin/_cctally_doctor.py +201 -26
  16. package/bin/_cctally_five_hour.py +110 -69
  17. package/bin/_cctally_forecast.py +112 -43
  18. package/bin/_cctally_journal.py +591 -80
  19. package/bin/_cctally_milestones.py +180 -67
  20. package/bin/_cctally_parser.py +87 -0
  21. package/bin/_cctally_percent_breakdown.py +24 -15
  22. package/bin/_cctally_project.py +12 -1
  23. package/bin/_cctally_quota.py +150 -39
  24. package/bin/_cctally_record.py +491 -141
  25. package/bin/_cctally_reporting.py +66 -14
  26. package/bin/_cctally_setup.py +9 -1
  27. package/bin/_cctally_source_analytics.py +56 -7
  28. package/bin/_cctally_statusline.py +56 -8
  29. package/bin/_cctally_store.py +20 -8
  30. package/bin/_cctally_sync_week.py +30 -10
  31. package/bin/_cctally_tui.py +99 -18
  32. package/bin/_cctally_weekrefs.py +38 -15
  33. package/bin/_lib_accounts.py +325 -0
  34. package/bin/_lib_alerts_payload.py +25 -4
  35. package/bin/_lib_cache_writer_lock.py +77 -0
  36. package/bin/_lib_codex_hooks.py +40 -1
  37. package/bin/_lib_diff_kernel.py +28 -14
  38. package/bin/_lib_doctor.py +160 -0
  39. package/bin/_lib_journal.py +65 -2
  40. package/bin/_lib_pricing.py +27 -3
  41. package/bin/_lib_quota.py +81 -4
  42. package/bin/_lib_render.py +19 -5
  43. package/bin/_lib_share.py +25 -0
  44. package/bin/_lib_snapshot_cache.py +8 -0
  45. package/bin/_lib_subscription_weeks.py +16 -2
  46. package/bin/_lib_view_models.py +18 -9
  47. package/bin/cctally +43 -4
  48. package/dashboard/static/assets/index-DJP4gEB7.js +92 -0
  49. package/dashboard/static/assets/index-Dk1nplOz.css +1 -0
  50. package/dashboard/static/dashboard.html +2 -2
  51. package/package.json +4 -1
  52. package/dashboard/static/assets/index-BzWujBS3.js +0 -92
  53. package/dashboard/static/assets/index-Cc2ykqF_.css +0 -1
@@ -302,6 +302,20 @@ def _add_source_args(
302
302
  )
303
303
 
304
304
 
305
+ def _add_account_arg(parser: argparse.ArgumentParser) -> None:
306
+ """Attach the ``--account <ref>`` render filter (#341, spec §3).
307
+
308
+ Default ``None`` -> the merged view, byte-identical to today (R8). The ref is
309
+ resolved at command time via ``_cctally_account.resolve_account_filter``
310
+ (case-insensitive label -> email -> unique key-prefix; ``unattributed``
311
+ accepted literally; ambiguous/unknown -> exit 2). Idempotent per parser."""
312
+ parser.add_argument(
313
+ "--account", metavar="REF", default=None,
314
+ help="Scope output to one account (label / email / account-key prefix / "
315
+ "'unattributed'). Ambiguous or unknown ref exits 2.",
316
+ )
317
+
318
+
305
319
  def _add_share_args(
306
320
  parser, *, has_status_line: bool = False, json_dest: str = "json",
307
321
  ) -> None:
@@ -509,6 +523,7 @@ def _build_daily_parser(subparsers, name, *, help_text, xref):
509
523
  _add_ccusage_alias_args(p, ansi_emit=False)
510
524
  _add_mode_arg(p)
511
525
  _add_share_args(p)
526
+ _add_account_arg(p)
512
527
  p.set_defaults(func=c.cmd_daily)
513
528
  return p
514
529
 
@@ -561,6 +576,7 @@ def _build_monthly_parser(subparsers, name, *, help_text, xref):
561
576
  _add_ccusage_alias_args(p, ansi_emit=False)
562
577
  _add_mode_arg(p)
563
578
  _add_share_args(p)
579
+ _add_account_arg(p)
564
580
  p.set_defaults(func=c.cmd_monthly)
565
581
  return p
566
582
 
@@ -603,6 +619,7 @@ def _build_weekly_parser(subparsers, name, *, help_text, xref):
603
619
  _add_ccusage_alias_args(p, ansi_emit=False)
604
620
  _add_mode_arg(p)
605
621
  _add_share_args(p)
622
+ _add_account_arg(p)
606
623
  p.set_defaults(func=c.cmd_weekly)
607
624
  return p
608
625
 
@@ -656,6 +673,7 @@ def _build_session_parser(subparsers, name, *, help_text, xref):
656
673
  _add_ccusage_alias_args(p, ansi_emit=False)
657
674
  _add_mode_arg(p)
658
675
  _add_share_args(p)
676
+ _add_account_arg(p)
659
677
  p.set_defaults(func=c.cmd_session)
660
678
  return p
661
679
 
@@ -1083,6 +1101,9 @@ def _add_codex_quota_common_args(parser, *, sync_by_default: bool = True) -> Non
1083
1101
  "--json", action="store_true",
1084
1102
  help="Emit stamped schemaVersion:1 JSON.",
1085
1103
  )
1104
+ # #341 --account: scope the native quota views to one Codex account
1105
+ # (provider="codex"). Shared by all 5 quota leaves via this chokepoint.
1106
+ _add_account_arg(parser)
1086
1107
 
1087
1108
 
1088
1109
  def _build_codex_quota_parser(subparsers, name, *, help_text, xref=None):
@@ -1374,6 +1395,7 @@ def _build_report_parser(subparsers, name, *, help_text, xref=None, fixed_source
1374
1395
  )
1375
1396
  _add_source_args(pr, fixed_source=fixed_source, speed=True)
1376
1397
  _add_share_args(pr)
1398
+ _add_account_arg(pr)
1377
1399
  pr.set_defaults(func=c.cmd_report)
1378
1400
 
1379
1401
  def _build_forecast_parser(subparsers, name, *, help_text, xref=None):
@@ -1424,6 +1446,7 @@ def _build_forecast_parser(subparsers, name, *, help_text, xref=None):
1424
1446
  help="Color output control (also honors NO_COLOR).")
1425
1447
  fc.add_argument("--as-of", dest="as_of", default=None, help=argparse.SUPPRESS)
1426
1448
  _add_share_args(fc, has_status_line=True)
1449
+ _add_account_arg(fc)
1427
1450
  fc.set_defaults(func=c.cmd_forecast)
1428
1451
 
1429
1452
  def _build_budget_parser(subparsers, name, *, help_text, xref=None):
@@ -1545,6 +1568,7 @@ def _build_percent_breakdown_parser(subparsers, name, *, help_text, xref=None):
1545
1568
  help="Display timezone: local, utc, or IANA name. "
1546
1569
  "Overrides config display.tz for this call.",
1547
1570
  )
1571
+ _add_account_arg(pb)
1548
1572
  pb.set_defaults(func=c.cmd_percent_breakdown)
1549
1573
 
1550
1574
  def _build_five_hour_breakdown_parser(subparsers, name, *, help_text, xref=None):
@@ -1606,6 +1630,7 @@ def _build_five_hour_breakdown_parser(subparsers, name, *, help_text, xref=None)
1606
1630
  help="Display timezone: local, utc, or IANA name. "
1607
1631
  "Overrides config display.tz for this call.",
1608
1632
  )
1633
+ _add_account_arg(fhbd)
1609
1634
  fhbd.set_defaults(func=c.cmd_five_hour_breakdown)
1610
1635
 
1611
1636
  def _build_tui_parser(subparsers, name, *, help_text, xref=None):
@@ -2026,6 +2051,7 @@ def _build_cache_report_parser(subparsers, name, *, help_text, xref=None, fixed_
2026
2051
  _add_ccusage_alias_args(pc, ansi_emit=False)
2027
2052
  _add_source_args(pc, fixed_source=fixed_source, speed=True)
2028
2053
  _add_share_args(pc)
2054
+ _add_account_arg(pc)
2029
2055
  pc.set_defaults(func=c.cmd_cache_report)
2030
2056
 
2031
2057
  def _build_range_cost_parser(subparsers, name, *, help_text, xref=None, fixed_source=None):
@@ -2093,6 +2119,7 @@ def _build_range_cost_parser(subparsers, name, *, help_text, xref=None, fixed_so
2093
2119
  _add_ccusage_alias_args(rc, ansi_emit=False)
2094
2120
  _add_source_args(rc, fixed_source=fixed_source, speed=True)
2095
2121
  _add_share_args(rc)
2122
+ _add_account_arg(rc)
2096
2123
  rc.set_defaults(func=c.cmd_range_cost)
2097
2124
 
2098
2125
  def _build_five_hour_blocks_parser(subparsers, name, *, help_text, xref=None):
@@ -2150,6 +2177,7 @@ def _build_five_hour_blocks_parser(subparsers, name, *, help_text, xref=None):
2150
2177
  _add_ccusage_alias_args(fhb, ansi_emit=False)
2151
2178
  _add_mode_arg(fhb, noop=True)
2152
2179
  _add_share_args(fhb)
2180
+ _add_account_arg(fhb)
2153
2181
  fhb.set_defaults(func=c.cmd_five_hour_blocks)
2154
2182
 
2155
2183
  def _build_cache_sync_parser(subparsers, name, *, help_text, xref=None):
@@ -2266,6 +2294,7 @@ def _build_project_parser(subparsers, name, *, help_text, xref=None, fixed_sourc
2266
2294
  _add_ccusage_alias_args(p_project, ansi_emit=True)
2267
2295
  _add_source_args(p_project, fixed_source=fixed_source, speed=True)
2268
2296
  _add_share_args(p_project)
2297
+ _add_account_arg(p_project)
2269
2298
  p_project.set_defaults(func=c.cmd_project)
2270
2299
 
2271
2300
  def _build_diff_parser(subparsers, name, *, help_text, xref=None, fixed_source=None):
@@ -2316,6 +2345,7 @@ def _build_diff_parser(subparsers, name, *, help_text, xref=None, fixed_source=N
2316
2345
  _add_ccusage_alias_args(diff_p, ansi_emit=True)
2317
2346
  _add_source_args(diff_p, fixed_source=fixed_source, speed=True)
2318
2347
  _add_share_args(diff_p, json_dest="emit_json")
2348
+ _add_account_arg(diff_p)
2319
2349
  diff_p.set_defaults(func=c.cmd_diff)
2320
2350
 
2321
2351
  def _build_claude_parser(subparsers, name, *, help_text, xref=None):
@@ -2519,6 +2549,62 @@ def _build_telemetry_parser(subparsers, name, *, help_text, xref=None):
2519
2549
  )
2520
2550
  tele_p.set_defaults(func=c.cmd_telemetry)
2521
2551
 
2552
+ def _build_account_parser(subparsers, name, *, help_text, xref=None):
2553
+ """Build the `account` parser (registered via _REGISTRATION; #341 Task 3).
2554
+
2555
+ ``list`` / ``show <ref>`` / ``label <ref> <name>`` over the accounts
2556
+ registry. Call-time ``c = _cctally()`` binding, mirroring the config/db
2557
+ nested-subcommand shape.
2558
+ """
2559
+ c = _cctally()
2560
+ acct_p = subparsers.add_parser(
2561
+ name,
2562
+ help=help_text,
2563
+ formatter_class=CLIHelpFormatter,
2564
+ description=textwrap.dedent("""\
2565
+ Inspect the per-provider account registry (multi-account, #341).
2566
+
2567
+ cctally observes which Claude/Codex account each usage sample was
2568
+ written under (from the provider's own on-disk credential state)
2569
+ and records milestones/quota/alerts per account. This subcommand
2570
+ lists the observed accounts, shows one account's identity +
2571
+ attribution summary, and sets a durable friendly label.
2572
+
2573
+ A <ref> is resolved case-insensitively by label, then email, then
2574
+ a unique account-key prefix; the literal 'unattributed' is accepted
2575
+ for the pre-feature / unresolved bucket. An ambiguous or unknown ref
2576
+ exits 2 with the candidate keys on stderr.
2577
+
2578
+ Actions:
2579
+ list List every observed account (add --json for the
2580
+ stamped-first camelCase envelope).
2581
+ show <ref> Show one account's identity + attribution summary.
2582
+ label <ref> <name>
2583
+ Set a durable user label (survives db rebuild).
2584
+
2585
+ Examples:
2586
+ cctally account list
2587
+ cctally account list --json
2588
+ cctally account show work@example.com
2589
+ cctally account label 3f2a1b work
2590
+ """),
2591
+ )
2592
+ acct_sub = acct_p.add_subparsers(dest="account_action", required=True)
2593
+ acct_list = acct_sub.add_parser("list", help="List observed accounts")
2594
+ acct_list.add_argument("--json", dest="emit_json", action="store_true",
2595
+ help="Emit the stamped-first camelCase JSON envelope.")
2596
+ acct_list.set_defaults(func=c.cmd_account)
2597
+ acct_show = acct_sub.add_parser("show", help="Show one account's detail")
2598
+ acct_show.add_argument("ref", help="Account ref (label / email / key prefix)")
2599
+ acct_show.add_argument("--json", dest="emit_json", action="store_true",
2600
+ help="Emit the stamped-first camelCase JSON envelope.")
2601
+ acct_show.set_defaults(func=c.cmd_account)
2602
+ acct_label = acct_sub.add_parser("label", help="Set a durable account label")
2603
+ acct_label.add_argument("ref", help="Account ref (label / email / key prefix)")
2604
+ acct_label.add_argument("label", help="New label")
2605
+ acct_label.set_defaults(func=c.cmd_account)
2606
+
2607
+
2522
2608
  def _build_alerts_parser(subparsers, name, *, help_text, xref=None):
2523
2609
  """Build the `alerts` parser (registered via _REGISTRATION; #279 S6 W3).
2524
2610
 
@@ -3277,6 +3363,7 @@ _REGISTRATION = (
3277
3363
  _Reg('claude', _build_claude_parser, "Claude-source reports (drop-in for `ccusage claude …`)", None, None),
3278
3364
  _Reg('codex', _build_codex_parser, "Codex-source reports (drop-in for `ccusage codex …`)", None, None),
3279
3365
  _Reg('config', _build_config_parser, "Get / set / unset persisted user preferences", None, None),
3366
+ _Reg('account', _build_account_parser, "Inspect per-provider account registry (list / show / label)", None, None),
3280
3367
  _Reg('telemetry', _build_telemetry_parser, "Show or change anonymous install-count telemetry", None, None),
3281
3368
  _Reg('alerts', _build_alerts_parser, "Manage threshold alerts", None, None),
3282
3369
  _Reg('setup', _build_setup_parser, "Install cctally into Claude Code (hooks + symlinks)", None, None),
@@ -80,6 +80,14 @@ def _render_percent_breakdown_terminal(
80
80
 
81
81
  def cmd_percent_breakdown(args: argparse.Namespace) -> int:
82
82
  c = _cctally()
83
+ # #341 --account: resolve the render filter (provider=claude). This command
84
+ # reads only stats tables (percent_milestones / weekly_usage_snapshots), not
85
+ # the entry cache, so needs_cache=False. None = merged / byte-stable.
86
+ acct_key, acct_exit = c.resolve_account_filter(args, "claude", needs_cache=False)
87
+ if acct_exit is not None:
88
+ return acct_exit
89
+ _acct_pred = "" if acct_key is None else " AND account_key = ?"
90
+ _acct_p = () if acct_key is None else (acct_key,)
83
91
  config = c.load_config()
84
92
  tz = c.resolve_display_tz(args, config)
85
93
  args._resolved_tz = tz
@@ -91,12 +99,10 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
91
99
  week_start = parse_date_str(args.week_start, "--week-start")
92
100
  else:
93
101
  latest_usage = conn.execute(
94
- """
95
- SELECT week_start_date
96
- FROM weekly_usage_snapshots
97
- ORDER BY captured_at_utc DESC, id DESC
98
- LIMIT 1
99
- """
102
+ "SELECT week_start_date FROM weekly_usage_snapshots"
103
+ + (" WHERE 1=1" + _acct_pred if acct_key is not None else "")
104
+ + " ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
105
+ _acct_p,
100
106
  ).fetchone()
101
107
  if latest_usage is not None:
102
108
  week_start = dt.date.fromisoformat(latest_usage["week_start_date"])
@@ -109,17 +115,18 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
109
115
 
110
116
  # Get week_end from any snapshot for this week
111
117
  end_row = conn.execute(
112
- """
118
+ f"""
113
119
  SELECT MAX(week_end_date) AS week_end_date
114
120
  FROM (
115
- SELECT week_end_date FROM weekly_usage_snapshots WHERE week_start_date = ?
121
+ SELECT week_end_date FROM weekly_usage_snapshots WHERE week_start_date = ?{_acct_pred}
116
122
  UNION ALL
117
- SELECT week_end_date FROM weekly_cost_snapshots WHERE week_start_date = ?
123
+ SELECT week_end_date FROM weekly_cost_snapshots WHERE week_start_date = ?{_acct_pred}
118
124
  UNION ALL
119
- SELECT week_end_date FROM percent_milestones WHERE week_start_date = ?
125
+ SELECT week_end_date FROM percent_milestones WHERE week_start_date = ?{_acct_pred}
120
126
  )
121
127
  """,
122
- (week_start_date, week_start_date, week_start_date),
128
+ (week_start_date,) + _acct_p + (week_start_date,) + _acct_p
129
+ + (week_start_date,) + _acct_p,
123
130
  ).fetchone()
124
131
  week_end_date = end_row["week_end_date"] if end_row and end_row["week_end_date"] else (
125
132
  (week_start + dt.timedelta(days=6)).isoformat()
@@ -158,9 +165,9 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
158
165
  canon_end_for_lookup = None
159
166
  latest_end_row = conn.execute(
160
167
  "SELECT week_end_at FROM weekly_usage_snapshots "
161
- "WHERE week_start_date = ? AND week_end_at IS NOT NULL "
162
- "ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
163
- (week_start_date,),
168
+ "WHERE week_start_date = ? AND week_end_at IS NOT NULL" + _acct_pred +
169
+ " ORDER BY captured_at_utc DESC, id DESC LIMIT 1",
170
+ (week_start_date,) + _acct_p,
164
171
  ).fetchone()
165
172
  if latest_end_row is not None:
166
173
  canon_end_for_lookup = _canonicalize_optional_iso(
@@ -177,7 +184,8 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
177
184
  active_segment = int(seg_row["id"])
178
185
 
179
186
  milestones = [
180
- m for m in c.get_milestones_for_week(conn, week_start_date)
187
+ m for m in c.get_milestones_for_week(
188
+ conn, week_start_date, account_key=acct_key)
181
189
  if int(m["reset_event_id"] or 0) == active_segment
182
190
  ]
183
191
 
@@ -201,6 +209,7 @@ def cmd_percent_breakdown(args: argparse.Namespace) -> int:
201
209
  }
202
210
 
203
211
  if args.json:
212
+ output.update(c.account_json_fields(acct_key)) # #341 R8 decoration
204
213
  print(json.dumps(c.stamp_schema_version(output), indent=2))
205
214
  return 0
206
215
 
@@ -423,6 +423,7 @@ def cmd_project(args: argparse.Namespace) -> int:
423
423
  # fallback for non-Monday-reset accounts).
424
424
  current_weeks = c._compute_subscription_weeks(
425
425
  conn, now, now + dt.timedelta(microseconds=1), config=config,
426
+ account_key=None, # #341: merged (all-accounts) analytics read
426
427
  )
427
428
  if current_weeks:
428
429
  cw_start = parse_iso_datetime(
@@ -444,6 +445,7 @@ def cmd_project(args: argparse.Namespace) -> int:
444
445
  # `_aggregate_weekly`'s bisect pattern (first-match-wins on overlap).
445
446
  subweeks = c._compute_subscription_weeks(
446
447
  conn, since_dt, until_dt, config=config,
448
+ account_key=None, # #341: merged (all-accounts) analytics read
447
449
  )
448
450
  parsed_bounds: list[tuple[dt.datetime, dt.datetime]] = []
449
451
  for sw in subweeks:
@@ -489,6 +491,13 @@ def cmd_project(args: argparse.Namespace) -> int:
489
491
  else:
490
492
  scan_start, scan_end = since_dt, until_dt
491
493
 
494
+ # #341 --account: resolve the render filter (provider=claude; fail closed
495
+ # with exit 3 when the entry cache is unavailable). None = merged.
496
+ acct_key, acct_exit = c.resolve_account_filter(
497
+ args, "claude", needs_cache=True)
498
+ if acct_exit is not None:
499
+ return acct_exit
500
+
492
501
  resolver_cache: dict[str, ProjectKey] = {}
493
502
  buckets: dict[tuple[ProjectKey, dt.datetime], dict] = {}
494
503
  total_cost_by_week: dict[dt.datetime, float] = {}
@@ -502,7 +511,8 @@ def cmd_project(args: argparse.Namespace) -> int:
502
511
  # aggregation semantics (denominator widened to ALL entries; visible
503
512
  # rows only the post-filter subset). The list is small enough to
504
513
  # hold (entries already in memory via the cache row factory).
505
- joined_entries_all = list(c.get_claude_session_entries(scan_start, scan_end))
514
+ joined_entries_all = list(c.get_claude_session_entries(
515
+ scan_start, scan_end, account_key=acct_key))
506
516
 
507
517
  # Build the --debug report dataset: skip synthetic + out-of-window
508
518
  # entries, then apply --model and --project filters (mirroring the
@@ -788,6 +798,7 @@ def cmd_project(args: argparse.Namespace) -> int:
788
798
  include_breakdown=args.breakdown,
789
799
  week_snapshots=week_snapshots,
790
800
  )
801
+ payload.update(c.account_json_fields(acct_key)) # #341 R8 decoration
791
802
  sink = getattr(args, "_source_result_sink", None)
792
803
  if sink is not None:
793
804
  sink(payload)