cctally 1.68.0 → 1.69.1

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 +61 -0
  2. package/bin/_cctally_alerts.py +54 -2
  3. package/bin/_cctally_cache.py +644 -107
  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 +168 -5
  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 +303 -17
  13. package/bin/_cctally_diff.py +49 -16
  14. package/bin/_cctally_doctor.py +118 -0
  15. package/bin/_cctally_forecast.py +12 -4
  16. package/bin/_cctally_parser.py +298 -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 +202 -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 +324 -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
package/bin/cctally CHANGED
@@ -86,6 +86,16 @@ def _load_sibling(name: str):
86
86
  Registers in sys.modules BEFORE exec_module so dataclass(frozen=True)
87
87
  paths that inspect cls.__module__'s sys.modules entry work correctly.
88
88
 
89
+ Thread-safe (#306). That pre-registration opens a window — the module is in
90
+ sys.modules but its top-level names are not yet bound — which, under the
91
+ dashboard's ThreadingHTTPServer, let a second request thread observe the
92
+ half-initialized module and AttributeError on a not-yet-bound name (e.g.
93
+ `_lib_transcript_access.transcripts_allowed`), then self-heal once the body
94
+ finished. `_SIBLING_LOCK` serializes the load so a concurrent caller blocks
95
+ until the body completes; `_LOADED_SIBLINGS` gates a lock-free fast path for
96
+ already-loaded siblings. Fail-closed by construction — a race waits, it never
97
+ returns a partial module early.
98
+
89
99
  For `_cctally_*.py` siblings that back-reference `cctally`, the sibling
90
100
  module exposes a `_cctally()` accessor that reads `sys.modules['cctally']`
91
101
  at call-time (spec §5.5). We re-pin `sys.modules['cctally']` on every
@@ -104,16 +114,53 @@ def _load_sibling(name: str):
104
114
  # reference to whichever bin/cctally module instance owns this
105
115
  # _load_sibling closure — regardless of pytest re-imports.
106
116
  sys.modules["cctally"] = _THIS_MODULE
107
- cached = sys.modules.get(name)
108
- if cached is not None:
109
- return cached
110
- import importlib.util as _ilu
111
- p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
112
- spec = _ilu.spec_from_file_location(name, p)
113
- mod = _ilu.module_from_spec(spec)
114
- sys.modules[name] = mod
115
- spec.loader.exec_module(mod)
116
- return mod
117
+ # Fast path: a sibling whose body has FULLY executed is returned without
118
+ # contending on the load lock. `_LOADED_SIBLINGS` is only ever added to,
119
+ # after a successful exec_module (below), so its membership is a stable
120
+ # "complete" signal — keeping the hot path lock-free for the ~50 sequential
121
+ # sibling loads at CLI startup and every warm request-thread lazy-load.
122
+ if name in _LOADED_SIBLINGS:
123
+ return sys.modules[name]
124
+ # Slow path — serialize the actual load (#306). Any concurrent caller blocks
125
+ # here until the owning thread finishes exec_module and records the name
126
+ # complete, so it can never observe the half-initialized module.
127
+ with _SIBLING_LOCK:
128
+ # Completed while we waited for the lock.
129
+ if name in _LOADED_SIBLINGS:
130
+ return sys.modules[name]
131
+ cached = sys.modules.get(name)
132
+ if cached is not None:
133
+ # Registered but not yet recorded complete. Under the lock this is
134
+ # NOT another thread mid-load (that thread would hold the lock) — it
135
+ # is either OUR OWN thread re-entering mid-exec_module (a circular
136
+ # import: return the partial module, matching CPython's semantics and
137
+ # the prior behavior) or a module some normal `import` already fully
138
+ # loaded. Either way the existing sys.modules entry is the object to
139
+ # return; do NOT record it complete here — a circular partial must
140
+ # never be published to the lock-free fast path (only the owning
141
+ # exec_module below does that, once the body has finished).
142
+ return cached
143
+ import importlib.util as _ilu
144
+ p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
145
+ # The target itself is located by path, but leaf siblings may use a
146
+ # normal ``from _lib_x import ...`` import. SourceFileLoader / exec
147
+ # callers do not necessarily put bin/ on sys.path, so make the sibling
148
+ # directory available before executing that module.
149
+ bin_dir = str(p.parent)
150
+ if bin_dir not in sys.path:
151
+ sys.path.insert(0, bin_dir)
152
+ spec = _ilu.spec_from_file_location(name, p)
153
+ mod = _ilu.module_from_spec(spec)
154
+ sys.modules[name] = mod
155
+ try:
156
+ spec.loader.exec_module(mod)
157
+ except BaseException:
158
+ # Failed mid-body — unregister so a later call retries cleanly and a
159
+ # broken module is never recorded complete (mirrors importlib).
160
+ sys.modules.pop(name, None)
161
+ raise
162
+ _LOADED_SIBLINGS.add(name)
163
+ return mod
117
164
 
118
165
 
119
166
  # Stable reference to THIS bin/cctally module instance, used by
@@ -133,6 +180,19 @@ def _load_sibling(name: str):
133
180
  _THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")
134
181
 
135
182
 
183
+ # Thread-safety for _load_sibling (#306). The dashboard's ThreadingHTTPServer
184
+ # serves each request on its own thread, and the transcript- / perf- /
185
+ # conversation-path siblings are lazy-loaded on those threads. `_SIBLING_LOCK`
186
+ # serializes the actual load so a concurrent caller can never observe a module
187
+ # that is registered in sys.modules but whose exec_module is still mid-body (the
188
+ # pre-registration window below), and `_LOADED_SIBLINGS` records the names whose
189
+ # bodies have fully executed, gating a lock-free fast path. RLock (not Lock) so a
190
+ # circular import that re-enters _load_sibling on the SAME thread during
191
+ # exec_module cannot self-deadlock.
192
+ _SIBLING_LOCK = threading.RLock()
193
+ _LOADED_SIBLINGS: set[str] = set()
194
+
195
+
136
196
  # Hook-tick non-path constants (Section 1 of onboarding spec).
137
197
  HOOK_TICK_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB
138
198
  HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
@@ -252,6 +312,15 @@ HOOK_TICK_LOG_ROTATED_PATH = _cctally_core.HOOK_TICK_LOG_ROTATED_PATH
252
312
  HOOK_TICK_THROTTLE_PATH = _cctally_core.HOOK_TICK_THROTTLE_PATH
253
313
  HOOK_TICK_THROTTLE_LOCK_PATH = _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH
254
314
 
315
+ # Statusline usage-persistence markers + tunables (spec 2026-07-17).
316
+ STATUSLINE_OBSERVE_MARKER_PATH = _cctally_core.STATUSLINE_OBSERVE_MARKER_PATH
317
+ STATUSLINE_PERSIST_LOCK_PATH = _cctally_core.STATUSLINE_PERSIST_LOCK_PATH
318
+ OAUTH_BACKOFF_MARKER_PATH = _cctally_core.OAUTH_BACKOFF_MARKER_PATH
319
+ STATUSLINE_PERSIST_THROTTLE_SECONDS = _cctally_core.STATUSLINE_PERSIST_THROTTLE_SECONDS
320
+ OAUTH_BACKFILL_STALE_SECONDS = _cctally_core.OAUTH_BACKFILL_STALE_SECONDS
321
+ OAUTH_BACKOFF_BASE_SECONDS = _cctally_core.OAUTH_BACKOFF_BASE_SECONDS
322
+ OAUTH_BACKOFF_CAP_SECONDS = _cctally_core.OAUTH_BACKOFF_CAP_SECONDS
323
+
255
324
  UPDATE_STATE_PATH = _cctally_core.UPDATE_STATE_PATH
256
325
  UPDATE_SUPPRESS_PATH = _cctally_core.UPDATE_SUPPRESS_PATH
257
326
  UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
@@ -375,6 +444,7 @@ _build_codex_daily_parser = _cctally_parser._build_codex_daily_parser
375
444
  _build_codex_monthly_parser = _cctally_parser._build_codex_monthly_parser
376
445
  _build_codex_weekly_parser = _cctally_parser._build_codex_weekly_parser
377
446
  _build_codex_session_parser = _cctally_parser._build_codex_session_parser
447
+ _build_codex_quota_parser = _cctally_parser._build_codex_quota_parser
378
448
 
379
449
 
380
450
  _lib_alert_axes = _load_sibling("_lib_alert_axes")
@@ -426,6 +496,37 @@ _CODEX_FILENAME_UUID_RE = _lib_jsonl._CODEX_FILENAME_UUID_RE
426
496
  _CodexIterState = _lib_jsonl._CodexIterState
427
497
  _iter_codex_jsonl_entries_with_offsets = _lib_jsonl._iter_codex_jsonl_entries_with_offsets
428
498
 
499
+ # Provider-neutral quota interpretation kernel (#294 S2). The module is pure
500
+ # and contains no database, config, filesystem, notifier, parser, or command
501
+ # dependency; adapters added in later stages own those concerns.
502
+ _lib_quota = _load_sibling("_lib_quota")
503
+ QuotaWindowIdentity = _lib_quota.QuotaWindowIdentity
504
+ QuotaObservation = _lib_quota.QuotaObservation
505
+ QuotaHistory = _lib_quota.QuotaHistory
506
+ QuotaBlock = _lib_quota.QuotaBlock
507
+ QuotaPercentMilestone = _lib_quota.QuotaPercentMilestone
508
+ QuotaFreshness = _lib_quota.QuotaFreshness
509
+ QuotaForecast = _lib_quota.QuotaForecast
510
+ QuotaRule = _lib_quota.QuotaRule
511
+ ResolvedQuotaRule = _lib_quota.ResolvedQuotaRule
512
+ QuotaThresholdDecision = _lib_quota.QuotaThresholdDecision
513
+ identity_sort_key = _lib_quota.identity_sort_key
514
+ physical_order_key = _lib_quota.physical_order_key
515
+ logical_value_tuple = _lib_quota.logical_value_tuple
516
+ source_path_key = _lib_quota.source_path_key
517
+ build_history = _lib_quota.build_history
518
+ latest_physical_observation = _lib_quota.latest_physical_observation
519
+ select_baseline = _lib_quota.select_baseline
520
+ build_blocks = _lib_quota.build_blocks
521
+ percent_milestones = _lib_quota.percent_milestones
522
+ stale_after_seconds = _lib_quota.stale_after_seconds
523
+ quota_freshness = _lib_quota.quota_freshness
524
+ forecast_quota = _lib_quota.forecast_quota
525
+ validate_thresholds = _lib_quota.validate_thresholds
526
+ resolve_quota_rule = _lib_quota.resolve_quota_rule
527
+ quota_rule_fingerprint = _lib_quota.quota_rule_fingerprint
528
+ quota_threshold_decisions = _lib_quota.quota_threshold_decisions
529
+
429
530
  _lib_blocks = _load_sibling("_lib_blocks")
430
531
  BLOCK_DURATION = _lib_blocks.BLOCK_DURATION
431
532
  Block = _lib_blocks.Block
@@ -628,6 +729,12 @@ _setup_data_dir_size_bytes = _cctally_setup._setup_data_dir_size_bytes
628
729
  _setup_format_bytes = _cctally_setup._setup_format_bytes
629
730
  _setup_recent_log_stats = _cctally_setup._setup_recent_log_stats
630
731
  _setup_compute_symlink_state = _cctally_setup._setup_compute_symlink_state
732
+ CodexHooksError = _cctally_setup.CodexHooksError
733
+ _codex_hooks_plan_install = _cctally_setup._codex_hooks_plan_install
734
+ _codex_hooks_plan_uninstall = _cctally_setup._codex_hooks_plan_uninstall
735
+ _write_codex_hooks_atomic = _cctally_setup._write_codex_hooks_atomic
736
+ _setup_codex_hook_roots = _cctally_setup._setup_codex_hook_roots
737
+ _setup_manage_codex_hooks = _cctally_setup._setup_manage_codex_hooks
631
738
  _setup_status = _cctally_setup._setup_status
632
739
  _setup_uninstall = _cctally_setup._setup_uninstall
633
740
  _setup_dry_run = _cctally_setup._setup_dry_run
@@ -660,6 +767,8 @@ _cctally_alerts = _load_sibling("_cctally_alerts")
660
767
  _alerts_log_path = _cctally_alerts._alerts_log_path
661
768
  _dispatch_alert_notification = _cctally_alerts._dispatch_alert_notification
662
769
  cmd_alerts_test = _cctally_alerts.cmd_alerts_test
770
+ _build_alert_payload_quota = _cctally_alerts._build_alert_payload_quota
771
+ _alert_text_quota = _cctally_alerts._alert_text_quota
663
772
 
664
773
  # Eager re-export of bin/_cctally_sync_week.py — `cmd_sync_week` is
665
774
  # called by bare name from non-extracted `cmd_record_usage` (the
@@ -746,6 +855,7 @@ _config_known_value = _cctally_config._config_known_value
746
855
  _cmd_config_get = _cctally_config._cmd_config_get
747
856
  _cmd_config_set = _cctally_config._cmd_config_set
748
857
  _cmd_config_unset = _cctally_config._cmd_config_unset
858
+ _get_quota_alerts_config = _cctally_config._get_quota_alerts_config
749
859
 
750
860
 
751
861
  # `cctally refresh-usage` + the OAuth-usage fetch/render/config surface,
@@ -768,6 +878,7 @@ CLAUDE_CODE_UA_FALLBACK_VERSION = _cctally_refresh.CLAUDE_CODE_UA_FALLBACK_VERSI
768
878
  _discover_cc_version = _cctally_refresh._discover_cc_version
769
879
  _resolve_oauth_usage_user_agent = _cctally_refresh._resolve_oauth_usage_user_agent
770
880
  _fetch_oauth_usage = _cctally_refresh._fetch_oauth_usage
881
+ _parse_retry_after = _cctally_refresh._parse_retry_after
771
882
  _REFRESH_USAGE_ANSI = _cctally_refresh._REFRESH_USAGE_ANSI
772
883
  _render_refresh_usage_text = _cctally_refresh._render_refresh_usage_text
773
884
  _serialize_refresh_usage_json = _cctally_refresh._serialize_refresh_usage_json
@@ -930,9 +1041,20 @@ _hook_tick_log_line = _cctally_record._hook_tick_log_line
930
1041
  _hook_tick_log_rotate_if_needed = _cctally_record._hook_tick_log_rotate_if_needed
931
1042
  _hook_tick_throttle_age_seconds = _cctally_record._hook_tick_throttle_age_seconds
932
1043
  _hook_tick_throttle_touch = _cctally_record._hook_tick_throttle_touch
1044
+ # Statusline usage-persistence markers (spec 2026-07-17). Re-exported so
1045
+ # `cctally.X` (the `_cctally()` accessor + test monkeypatch surface) resolves.
1046
+ _statusline_observe_age_seconds = _cctally_record._statusline_observe_age_seconds
1047
+ _statusline_observe_touch = _cctally_record._statusline_observe_touch
1048
+ _oauth_backoff_remaining_seconds = _cctally_record._oauth_backoff_remaining_seconds
1049
+ _oauth_backoff_set = _cctally_record._oauth_backoff_set
1050
+ _oauth_backoff_clear = _cctally_record._oauth_backoff_clear
1051
+ _oauth_backoff_count = _cctally_record._oauth_backoff_count
1052
+ _oauth_backoff_register_429 = _cctally_record._oauth_backoff_register_429
1053
+ _oauth_backoff_reset = _cctally_record._oauth_backoff_reset
933
1054
  _hook_tick_read_stdin_event = _cctally_record._hook_tick_read_stdin_event
934
1055
  _hook_tick_session_short = _cctally_record._hook_tick_session_short
935
1056
  _hook_tick_format_log_line = _cctally_record._hook_tick_format_log_line
1057
+ _cmd_hook_tick_codex = _cctally_record._cmd_hook_tick_codex
936
1058
  cmd_hook_tick = _cctally_record.cmd_hook_tick
937
1059
  _safe_float = _cctally_record._safe_float
938
1060
  _validate_date_optional = _cctally_record._validate_date_optional
@@ -1182,6 +1304,14 @@ cmd_statusline = _cctally_statusline.cmd_statusline
1182
1304
  _resolve_context_window = _cctally_statusline._resolve_context_window
1183
1305
  _read_last_assistant_usage = _cctally_statusline._read_last_assistant_usage
1184
1306
  _build_statusline_injections = _cctally_statusline._build_statusline_injections
1307
+ # Statusline usage-persistence feeder (spec 2026-07-17). Re-exported so
1308
+ # `cctally._statusline_persist` (test surface) + the `_cctally()` accessor
1309
+ # resolve them.
1310
+ _statusline_persist = _cctally_statusline._statusline_persist
1311
+ _fork_persist = _cctally_statusline._fork_persist
1312
+ _record_args = _cctally_statusline._record_args
1313
+ _try_acquire_persist_lock = _cctally_statusline._try_acquire_persist_lock
1314
+ _release_persist_lock = _cctally_statusline._release_persist_lock
1185
1315
 
1186
1316
 
1187
1317
  # Eager re-export of bin/_cctally_codex.py — the four cmd_codex_* are the
@@ -1205,6 +1335,50 @@ cmd_codex_monthly = _cctally_codex.cmd_codex_monthly
1205
1335
  cmd_codex_weekly = _cctally_codex.cmd_codex_weekly
1206
1336
  cmd_codex_session = _cctally_codex.cmd_codex_session
1207
1337
 
1338
+ # Eager re-export of bin/_cctally_quota.py — the five canonical nested
1339
+ # `cctally codex quota` leaves use the durable S2 projection/interpretation
1340
+ # adapter while retaining the existing Codex accounting commands unchanged.
1341
+ _cctally_quota = _load_sibling("_cctally_quota")
1342
+ reconcile_codex_quota_projection = _cctally_quota.reconcile_codex_quota_projection
1343
+ cmd_codex_quota_history = _cctally_quota.cmd_codex_quota_history
1344
+ cmd_codex_quota_statusline = _cctally_quota.cmd_codex_quota_statusline
1345
+ cmd_codex_quota_forecast = _cctally_quota.cmd_codex_quota_forecast
1346
+ cmd_codex_quota_blocks = _cctally_quota.cmd_codex_quota_blocks
1347
+ cmd_codex_quota_breakdown = _cctally_quota.cmd_codex_quota_breakdown
1348
+
1349
+ # S3's qualified Codex accounting adapter is intentionally cache-only: callers
1350
+ # that need project identity must not downgrade to an unqualified rollout parse.
1351
+ try:
1352
+ _cctally_source_analytics = _load_sibling("_cctally_source_analytics")
1353
+ QualifiedMetadataUnavailable = _cctally_source_analytics.QualifiedMetadataUnavailable
1354
+ load_qualified_codex_entries = _cctally_source_analytics.load_qualified_codex_entries
1355
+ cmd_source_project = _cctally_source_analytics.cmd_source_project
1356
+ cmd_source_diff = _cctally_source_analytics.cmd_source_diff
1357
+ cmd_source_range_cost = _cctally_source_analytics.cmd_source_range_cost
1358
+ cmd_source_cache_report = _cctally_source_analytics.cmd_source_cache_report
1359
+ cmd_source_report = _cctally_source_analytics.cmd_source_report
1360
+ validate_source_project_selectors = _cctally_source_analytics.validate_source_project_selectors
1361
+ _lib_source_analytics = _load_sibling("_lib_source_analytics")
1362
+ QualifiedCodexEntry = _lib_source_analytics.QualifiedCodexEntry
1363
+ SourceWarning = _lib_source_analytics.SourceWarning
1364
+ SourceResult = _lib_source_analytics.SourceResult
1365
+ opaque_project_key = _lib_source_analytics.opaque_project_key
1366
+ except Exception:
1367
+ # The Task 1 foundation remains private until its later CLI/share surfaces
1368
+ # are promoted; public mirror builds must retain their current import path.
1369
+ QualifiedMetadataUnavailable = None
1370
+ load_qualified_codex_entries = None
1371
+ QualifiedCodexEntry = None
1372
+ SourceWarning = None
1373
+ SourceResult = None
1374
+ opaque_project_key = None
1375
+ cmd_source_project = None
1376
+ cmd_source_diff = None
1377
+ cmd_source_range_cost = None
1378
+ cmd_source_cache_report = None
1379
+ cmd_source_report = None
1380
+ validate_source_project_selectors = None
1381
+
1208
1382
  # Eager re-export of bin/_cctally_reporting.py — the five cmd_* are the
1209
1383
  # parser's `set_defaults(func=c.cmd_*)` targets; `cmd_session` + the others
1210
1384
  # are retrieved by tests via `ns["…"]`. So EVERY moved symbol must live here.
@@ -2569,6 +2743,125 @@ _cctally_diff = _load_sibling("_cctally_diff")
2569
2743
  cmd_diff = _cctally_diff.cmd_diff # parser: c.cmd_diff (_cctally_parser.py:2032)
2570
2744
  _emit_diff_debug_samples = _cctally_diff._emit_diff_debug_samples # test_debug_sample_emission mod._emit_diff_debug_samples
2571
2745
 
2746
+
2747
+ # Provider-aware analytics deliberately wrap the established command entries
2748
+ # here, leaving their Claude implementation modules untouched below the branch.
2749
+ _cmd_project_claude = cmd_project
2750
+ _cmd_diff_claude = cmd_diff
2751
+ _cmd_range_cost_claude = cmd_range_cost
2752
+ _cmd_cache_report_claude = cmd_cache_report
2753
+ _cmd_report_claude = cmd_report
2754
+
2755
+
2756
+ def _dispatch_source_aware(args, claude_command, source_command):
2757
+ """Preserve ordinary Claude bytes; route share/non-Claude requests to S3."""
2758
+ if getattr(args, "source", "claude") == "claude":
2759
+ # S3 adds share artifacts to three legacy Claude commands. Keep the
2760
+ # established terminal and JSON routes exactly as they were, but route
2761
+ # the new format-only surface through the structured legacy-result
2762
+ # adapter. That adapter never captures or reparses stdout.
2763
+ if (
2764
+ getattr(args, "format", None)
2765
+ and getattr(args, "command", None) in {
2766
+ "diff", "range-cost", "cache-report",
2767
+ }
2768
+ ):
2769
+ if source_command is None:
2770
+ raise RuntimeError("provider-aware analytics are unavailable")
2771
+ return source_command(args)
2772
+ return claude_command(args)
2773
+ if source_command is None:
2774
+ raise RuntimeError("provider-aware analytics are unavailable")
2775
+ # The historical diff renderer calls this field ``emit_json`` while the
2776
+ # S3 source adapter uses the shared ``json`` spelling.
2777
+ if hasattr(args, "emit_json"):
2778
+ args.json = bool(args.emit_json)
2779
+ return source_command(args)
2780
+
2781
+
2782
+ def cmd_project(args):
2783
+ return _dispatch_source_aware(args, _cmd_project_claude, cmd_source_project)
2784
+
2785
+
2786
+ def cmd_diff(args):
2787
+ return _dispatch_source_aware(args, _cmd_diff_claude, cmd_source_diff)
2788
+
2789
+
2790
+ def cmd_range_cost(args):
2791
+ return _dispatch_source_aware(args, _cmd_range_cost_claude, cmd_source_range_cost)
2792
+
2793
+
2794
+ def cmd_cache_report(args):
2795
+ return _dispatch_source_aware(args, _cmd_cache_report_claude, cmd_source_cache_report)
2796
+
2797
+
2798
+ def cmd_report(args):
2799
+ return _dispatch_source_aware(args, _cmd_report_claude, cmd_source_report)
2800
+
2801
+
2802
+ def _validate_source_args(args) -> None:
2803
+ """Fail incompatible provider options before cache sync or rendering."""
2804
+ command = getattr(args, "command", None)
2805
+ if command not in {"project", "diff", "range-cost", "cache-report", "report"}:
2806
+ return
2807
+ source = getattr(args, "source", "claude")
2808
+ speed = getattr(args, "speed", "auto")
2809
+ if source == "claude" and speed != "auto":
2810
+ raise ValueError("--speed is only valid for Codex or all-source requests")
2811
+ if command == "project" and source in {"codex", "all"} and getattr(args, "sort", None) == "used":
2812
+ raise ValueError("project --sort used is only valid for Claude")
2813
+ if command in {"project", "range-cost", "cache-report"} and source in {"codex", "all"}:
2814
+ if validate_source_project_selectors is not None:
2815
+ validate_source_project_selectors(getattr(args, "project", None))
2816
+ if command == "range-cost" and source == "codex" and getattr(args, "mode", "auto") != "auto":
2817
+ raise ValueError("range-cost --mode is only valid for Claude")
2818
+ if command == "report" and source in {"codex", "all"}:
2819
+ weeks = getattr(args, "weeks", None)
2820
+ if not isinstance(weeks, int) or isinstance(weeks, bool) or weeks <= 0:
2821
+ raise ValueError("report --weeks must be a positive integer")
2822
+ if command == "report" and source == "codex":
2823
+ if getattr(args, "week_start_name", None) is not None:
2824
+ raise ValueError("report --week-start-name is only valid for Claude")
2825
+ if getattr(args, "mode", "auto") != "auto":
2826
+ raise ValueError("report --mode is only valid for Claude")
2827
+ if getattr(args, "offline", False):
2828
+ raise ValueError("report --offline is only valid for Claude")
2829
+ if getattr(args, "project", None):
2830
+ raise ValueError("report --project is only valid for Claude")
2831
+ if command == "cache-report":
2832
+ sort = getattr(args, "sort", None)
2833
+ if source == "claude" and sort == "reuse":
2834
+ raise ValueError("cache-report --sort reuse is only valid for Codex")
2835
+ if source == "codex" and sort in {"net", "cache", "anomaly"}:
2836
+ raise ValueError(f"cache-report --sort {sort} is only valid for Claude")
2837
+ if source == "codex" and (
2838
+ getattr(args, "anomaly_threshold_pp", 15) != 15
2839
+ or getattr(args, "anomaly_window_days", 14) != 14
2840
+ or getattr(args, "no_anomaly", False)
2841
+ ):
2842
+ raise ValueError("cache-report anomaly options are only valid for Claude")
2843
+ if command == "diff":
2844
+ explicit_only = getattr(args, "only", None)
2845
+ selected = {
2846
+ item.strip() for item in (explicit_only or "").split(",")
2847
+ if item.strip()
2848
+ }
2849
+ if source in {"codex", "all"} and explicit_only is not None and not selected:
2850
+ raise ValueError(
2851
+ "diff --only specified no sections. Supported: "
2852
+ "cache, models, overall, projects, token-reuse"
2853
+ )
2854
+ if source == "claude" and "token-reuse" in selected:
2855
+ raise ValueError("diff --only token-reuse is only valid for Codex")
2856
+ if source == "codex" and "cache" in selected:
2857
+ raise ValueError("diff --only cache is only valid for Claude")
2858
+ supported = {"overall", "models", "projects", "cache", "token-reuse"}
2859
+ unknown = selected - supported
2860
+ if unknown:
2861
+ raise ValueError(
2862
+ "diff --only contains unknown section(s): " + ", ".join(sorted(unknown))
2863
+ )
2864
+
2572
2865
  # #281 S4: the `transcript export|search` command module. Eager re-export so the
2573
2866
  # parser's set_defaults(func=c.cmd_transcript) resolves at build_parser time.
2574
2867
  _cctally_transcript = _load_sibling("_cctally_transcript")
@@ -2742,6 +3035,7 @@ _tui_build_session_detail = _cctally_tui._tui_build_session_detail
2742
3035
  _tui_build_session_detail_indexed = _cctally_tui._tui_build_session_detail_indexed
2743
3036
  _tui_build_snapshot = _cctally_tui._tui_build_snapshot
2744
3037
  _tui_empty_snapshot = _cctally_tui._tui_empty_snapshot
3038
+ _snapshot_data_version = _cctally_tui._snapshot_data_version # #300 change-signal helper
2745
3039
  # Key reader + dispatcher + sync thread base class
2746
3040
  TuiKeyReader = _cctally_tui.TuiKeyReader
2747
3041
  _tui_handle_key = _cctally_tui._tui_handle_key
@@ -2824,6 +3118,26 @@ def main(argv: list[str] | None = None) -> int:
2824
3118
  return 0
2825
3119
  if not getattr(args, "func", None):
2826
3120
  parser.error("a subcommand is required")
3121
+ # Validate share destination shape before the migration banner, config
3122
+ # load, cache sync, quota reconciliation, or any output side effect. The
3123
+ # command handlers repeat this idempotent check for direct-call safety.
3124
+ _share_validate_args(args)
3125
+ # Provider-aware parsers accept the common output destination flags but
3126
+ # defer several presentation defaults to the source share composer. Give
3127
+ # their outer namespace the established share defaults before any handler
3128
+ # can hand it to ``_share_render_and_emit``.
3129
+ for _share_name, _share_default in (
3130
+ ("theme", "light"),
3131
+ ("no_branding", False),
3132
+ ("reveal_projects", False),
3133
+ ):
3134
+ if not hasattr(args, _share_name):
3135
+ setattr(args, _share_name, _share_default)
3136
+ try:
3137
+ _validate_source_args(args)
3138
+ except ValueError as exc:
3139
+ eprint(f"cctally: {exc}")
3140
+ return 2
2827
3141
  _print_migration_error_banner_if_needed(args)
2828
3142
  try:
2829
3143
  rc = int(args.func(args))