cctally 1.7.3 → 1.8.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.
- package/CHANGELOG.md +22 -0
- package/README.md +1 -1
- package/bin/_cctally_alerts.py +12 -5
- package/bin/_cctally_cache.py +12 -11
- package/bin/_cctally_config.py +34 -19
- package/bin/_cctally_core.py +890 -0
- package/bin/_cctally_dashboard.py +175 -233
- package/bin/_cctally_db.py +89 -20
- package/bin/_cctally_record.py +76 -75
- package/bin/_cctally_refresh.py +35 -20
- package/bin/_cctally_setup.py +26 -16
- package/bin/_cctally_sync_week.py +21 -6
- package/bin/_cctally_tui.py +151 -306
- package/bin/_cctally_update.py +11 -16
- package/bin/_lib_aggregators.py +7 -1
- package/bin/_lib_diff_kernel.py +19 -21
- package/bin/_lib_subscription_weeks.py +17 -9
- package/bin/_lib_view_models.py +993 -0
- package/bin/cctally +338 -1055
- package/dashboard/static/assets/{index-DhCnIFq9.js → index-CfXu9Fx_.js} +1 -1
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +10 -8
package/bin/_cctally_record.py
CHANGED
|
@@ -156,25 +156,40 @@ def _cctally():
|
|
|
156
156
|
return sys.modules["cctally"]
|
|
157
157
|
|
|
158
158
|
|
|
159
|
+
# === Honest imports from extracted homes ===================================
|
|
160
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3.
|
|
161
|
+
from _cctally_core import (
|
|
162
|
+
eprint,
|
|
163
|
+
now_utc_iso,
|
|
164
|
+
parse_iso_datetime,
|
|
165
|
+
open_db,
|
|
166
|
+
get_week_start_name,
|
|
167
|
+
compute_week_bounds,
|
|
168
|
+
parse_date_str,
|
|
169
|
+
_canonicalize_optional_iso,
|
|
170
|
+
make_week_ref,
|
|
171
|
+
_get_alerts_config,
|
|
172
|
+
_AlertsConfigError,
|
|
173
|
+
)
|
|
174
|
+
from _lib_five_hour import _canonical_5h_window_key
|
|
175
|
+
from _lib_pricing import _calculate_entry_cost
|
|
176
|
+
|
|
177
|
+
|
|
159
178
|
# Module-level back-ref shims. Each shim resolves
|
|
160
179
|
# ``sys.modules['cctally'].X`` at CALL TIME (not bind time), so
|
|
161
180
|
# monkeypatches on cctally's namespace propagate into the moved code
|
|
162
|
-
# unchanged.
|
|
163
|
-
#
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
def
|
|
169
|
-
return sys.modules["cctally"].
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def parse_iso_datetime(*args, **kwargs):
|
|
173
|
-
return sys.modules["cctally"].parse_iso_datetime(*args, **kwargs)
|
|
181
|
+
# unchanged. `load_config` and `get_claude_session_entries` STAY as
|
|
182
|
+
# shims even though their natural homes are decentralized
|
|
183
|
+
# (_cctally_config / _cctally_cache) — tests monkeypatch them via
|
|
184
|
+
# `ns["X"]` (21 sites total, audited 2026-05-17); direct imports would
|
|
185
|
+
# silently bypass the patches.
|
|
186
|
+
# See spec §3.5 (carve-out) and §3.7 (stays-on-shim allowlist).
|
|
187
|
+
def load_config(*args, **kwargs):
|
|
188
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
174
189
|
|
|
175
190
|
|
|
176
|
-
def
|
|
177
|
-
return sys.modules["cctally"].
|
|
191
|
+
def get_claude_session_entries(*args, **kwargs):
|
|
192
|
+
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
178
193
|
|
|
179
194
|
|
|
180
195
|
def open_cache_db(*args, **kwargs):
|
|
@@ -185,30 +200,6 @@ def sync_cache(*args, **kwargs):
|
|
|
185
200
|
return sys.modules["cctally"].sync_cache(*args, **kwargs)
|
|
186
201
|
|
|
187
202
|
|
|
188
|
-
def load_config(*args, **kwargs):
|
|
189
|
-
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
def get_week_start_name(*args, **kwargs):
|
|
193
|
-
return sys.modules["cctally"].get_week_start_name(*args, **kwargs)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def compute_week_bounds(*args, **kwargs):
|
|
197
|
-
return sys.modules["cctally"].compute_week_bounds(*args, **kwargs)
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def parse_date_str(*args, **kwargs):
|
|
201
|
-
return sys.modules["cctally"].parse_date_str(*args, **kwargs)
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def _canonicalize_optional_iso(*args, **kwargs):
|
|
205
|
-
return sys.modules["cctally"]._canonicalize_optional_iso(*args, **kwargs)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _canonical_5h_window_key(*args, **kwargs):
|
|
209
|
-
return sys.modules["cctally"]._canonical_5h_window_key(*args, **kwargs)
|
|
210
|
-
|
|
211
|
-
|
|
212
203
|
def _floor_to_hour(*args, **kwargs):
|
|
213
204
|
return sys.modules["cctally"]._floor_to_hour(*args, **kwargs)
|
|
214
205
|
|
|
@@ -245,22 +236,10 @@ def insert_percent_milestone(*args, **kwargs):
|
|
|
245
236
|
return sys.modules["cctally"].insert_percent_milestone(*args, **kwargs)
|
|
246
237
|
|
|
247
238
|
|
|
248
|
-
def make_week_ref(*args, **kwargs):
|
|
249
|
-
return sys.modules["cctally"].make_week_ref(*args, **kwargs)
|
|
250
|
-
|
|
251
|
-
|
|
252
239
|
def cmd_sync_week(*args, **kwargs):
|
|
253
240
|
return sys.modules["cctally"].cmd_sync_week(*args, **kwargs)
|
|
254
241
|
|
|
255
242
|
|
|
256
|
-
def _calculate_entry_cost(*args, **kwargs):
|
|
257
|
-
return sys.modules["cctally"]._calculate_entry_cost(*args, **kwargs)
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def get_claude_session_entries(*args, **kwargs):
|
|
261
|
-
return sys.modules["cctally"].get_claude_session_entries(*args, **kwargs)
|
|
262
|
-
|
|
263
|
-
|
|
264
243
|
def _resolve_primary_model_for_block(*args, **kwargs):
|
|
265
244
|
return sys.modules["cctally"]._resolve_primary_model_for_block(*args, **kwargs)
|
|
266
245
|
|
|
@@ -281,10 +260,6 @@ def _dispatch_alert_notification(*args, **kwargs):
|
|
|
281
260
|
return sys.modules["cctally"]._dispatch_alert_notification(*args, **kwargs)
|
|
282
261
|
|
|
283
262
|
|
|
284
|
-
def _get_alerts_config(*args, **kwargs):
|
|
285
|
-
return sys.modules["cctally"]._get_alerts_config(*args, **kwargs)
|
|
286
|
-
|
|
287
|
-
|
|
288
263
|
def _warn_alerts_bad_config_once(*args, **kwargs):
|
|
289
264
|
return sys.modules["cctally"]._warn_alerts_bad_config_once(*args, **kwargs)
|
|
290
265
|
|
|
@@ -532,7 +507,7 @@ def maybe_record_milestone(
|
|
|
532
507
|
# the underlying problem is config-wide, not axis-specific.
|
|
533
508
|
try:
|
|
534
509
|
alerts_cfg: "dict | None" = _get_alerts_config(load_config())
|
|
535
|
-
except
|
|
510
|
+
except _AlertsConfigError as exc:
|
|
536
511
|
_warn_alerts_bad_config_once(exc)
|
|
537
512
|
alerts_cfg = None
|
|
538
513
|
|
|
@@ -803,7 +778,7 @@ def maybe_update_five_hour_block(saved: dict[str, Any]) -> None:
|
|
|
803
778
|
cfg_for_alerts = load_config()
|
|
804
779
|
try:
|
|
805
780
|
alerts_cfg: "dict | None" = _get_alerts_config(cfg_for_alerts)
|
|
806
|
-
except
|
|
781
|
+
except _AlertsConfigError as exc:
|
|
807
782
|
_warn_alerts_bad_config_once(exc)
|
|
808
783
|
alerts_cfg = None
|
|
809
784
|
# Resolve display.tz once (shares the cfg load above). Threaded
|
|
@@ -1500,12 +1475,19 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1500
1475
|
# UNIQUE(old, new) constraint permits this
|
|
1501
1476
|
# row, and the pre-check above keys on
|
|
1502
1477
|
# new_week_end_at so dedup still works.
|
|
1478
|
+
# Stamp ``observed_pre_credit_pct = prior_pct``
|
|
1479
|
+
# (issue #45): durable record of the pre-credit
|
|
1480
|
+
# baseline we observed at write time. Decouples
|
|
1481
|
+
# any future cleanup tooling from re-deriving
|
|
1482
|
+
# prior_pct via SELECT. Existing rows from
|
|
1483
|
+
# migration 007 carry NULL.
|
|
1503
1484
|
conn.execute(
|
|
1504
1485
|
"INSERT OR IGNORE INTO week_reset_events "
|
|
1505
1486
|
"(detected_at_utc, old_week_end_at, new_week_end_at, "
|
|
1506
|
-
" effective_reset_at_utc)
|
|
1487
|
+
" effective_reset_at_utc, observed_pre_credit_pct) "
|
|
1488
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
1507
1489
|
(now_utc_iso(), effective_iso, cur_end_canon,
|
|
1508
|
-
effective_iso),
|
|
1490
|
+
effective_iso, float(prior_pct)),
|
|
1509
1491
|
)
|
|
1510
1492
|
conn.commit()
|
|
1511
1493
|
# Pivots fire UNCONDITIONALLY whenever a credit
|
|
@@ -1553,24 +1535,29 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1553
1535
|
# own in-memory HWM cache and re-runs us once
|
|
1554
1536
|
# per status-line tick). Those replays land
|
|
1555
1537
|
# captured_at_utc >= effective_iso with
|
|
1556
|
-
# weekly_percent
|
|
1538
|
+
# weekly_percent near prior_pct (the pre-credit
|
|
1557
1539
|
# value), and they dominate the reset-aware
|
|
1558
1540
|
# clamp's MAX over the post-credit segment so
|
|
1559
1541
|
# legitimate fresh OAuth values are rejected.
|
|
1560
|
-
#
|
|
1561
|
-
#
|
|
1562
|
-
#
|
|
1563
|
-
#
|
|
1564
|
-
#
|
|
1565
|
-
#
|
|
1542
|
+
# 1.0pp tolerance band (issue #45) around the
|
|
1543
|
+
# observed pre-credit baseline absorbs any
|
|
1544
|
+
# rounding drift between cctally's OAuth read
|
|
1545
|
+
# and statusline's --percent payload (today
|
|
1546
|
+
# they match byte-identically, but the band
|
|
1547
|
+
# future-proofs against Anthropic or statusline
|
|
1548
|
+
# changing rounding). The band stays well below
|
|
1549
|
+
# the 25pp in-place credit detection threshold,
|
|
1550
|
+
# so legitimate post-credit values are never
|
|
1551
|
+
# caught. Bind is the in-scope ``prior_pct``,
|
|
1552
|
+
# which equals the just-stamped
|
|
1553
|
+
# ``observed_pre_credit_pct`` on the event row.
|
|
1566
1554
|
try:
|
|
1567
1555
|
conn.execute(
|
|
1568
1556
|
"DELETE FROM weekly_usage_snapshots "
|
|
1569
1557
|
"WHERE week_start_date = ? "
|
|
1570
1558
|
" AND unixepoch(captured_at_utc) >= "
|
|
1571
1559
|
" unixepoch(?) "
|
|
1572
|
-
" AND
|
|
1573
|
-
" round(?, 1)",
|
|
1560
|
+
" AND ABS(weekly_percent - ?) < 1.0",
|
|
1574
1561
|
(week_start_date, effective_iso,
|
|
1575
1562
|
float(prior_pct)),
|
|
1576
1563
|
)
|
|
@@ -1755,20 +1742,34 @@ def cmd_record_usage(args: argparse.Namespace) -> int:
|
|
|
1755
1742
|
# replaying the pre-credit
|
|
1756
1743
|
# ``--five-hour-percent`` value past the
|
|
1757
1744
|
# credit moment from its own in-memory
|
|
1758
|
-
# HWM cache.
|
|
1759
|
-
#
|
|
1760
|
-
#
|
|
1761
|
-
#
|
|
1762
|
-
#
|
|
1763
|
-
#
|
|
1745
|
+
# HWM cache. 1.0pp tolerance band (issue
|
|
1746
|
+
# #48 — symmetric follow-up to weekly #45)
|
|
1747
|
+
# around the observed pre-credit baseline
|
|
1748
|
+
# absorbs any rounding drift between
|
|
1749
|
+
# cctally's OAuth read and statusline's
|
|
1750
|
+
# ``--five-hour-percent`` payload (today
|
|
1751
|
+
# they match byte-identically, but the
|
|
1752
|
+
# band future-proofs against Anthropic or
|
|
1753
|
+
# statusline changing 5h rounding). The
|
|
1754
|
+
# band stays well below the 5.0pp 5h
|
|
1755
|
+
# in-place credit detection threshold
|
|
1756
|
+
# (``_FIVE_HOUR_RESET_PCT_DROP_THRESHOLD``)
|
|
1757
|
+
# — 4pp safety margin — so legitimate
|
|
1758
|
+
# post-credit values are never caught.
|
|
1759
|
+
# ``unixepoch()`` on both sides for offset
|
|
1760
|
+
# robustness (Z vs +00:00). Bind is the
|
|
1761
|
+
# in-scope ``prior_5h_pct``, which equals
|
|
1762
|
+
# the just-stamped
|
|
1763
|
+
# ``five_hour_reset_events.prior_percent``
|
|
1764
|
+
# on the event row.
|
|
1764
1765
|
try:
|
|
1765
1766
|
conn.execute(
|
|
1766
1767
|
"DELETE FROM weekly_usage_snapshots "
|
|
1767
1768
|
" WHERE five_hour_window_key = ? "
|
|
1768
1769
|
" AND unixepoch(captured_at_utc) "
|
|
1769
1770
|
" >= unixepoch(?) "
|
|
1770
|
-
" AND
|
|
1771
|
-
"
|
|
1771
|
+
" AND ABS(five_hour_percent - ?) "
|
|
1772
|
+
" < 1.0",
|
|
1772
1773
|
(
|
|
1773
1774
|
int(five_hour_window_key),
|
|
1774
1775
|
effective_iso,
|
package/bin/_cctally_refresh.py
CHANGED
|
@@ -55,6 +55,23 @@ def _cctally():
|
|
|
55
55
|
return sys.modules["cctally"]
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
# === Honest imports from extracted homes ===================================
|
|
59
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
60
|
+
# import from _cctally_core. `load_config` plus out-of-scope helpers
|
|
61
|
+
# (`_resolve_oauth_token`, `_fetch_oauth_usage`, `_bust_statusline_cache`,
|
|
62
|
+
# `_refresh_usage_inproc`, `_get_oauth_usage_config`,
|
|
63
|
+
# `_seconds_since_iso`, `_select_last_known_snapshot`,
|
|
64
|
+
# `_newest_snapshot_age_seconds`, `_normalize_percent`,
|
|
65
|
+
# `_forecast_color_enabled`, `_discover_cc_version`, `cmd_record_usage`)
|
|
66
|
+
# stay on the _cctally() accessor.
|
|
67
|
+
from _cctally_core import (
|
|
68
|
+
eprint,
|
|
69
|
+
now_utc_iso,
|
|
70
|
+
_iso_to_epoch,
|
|
71
|
+
_format_short_duration,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
58
75
|
# =========================================================================
|
|
59
76
|
# Exception classes
|
|
60
77
|
# =========================================================================
|
|
@@ -289,14 +306,13 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
|
|
|
289
306
|
The ``5h`` segment is omitted entirely when ``payload["five_hour"]`` is None.
|
|
290
307
|
Color codes are emitted only when ``color`` is True.
|
|
291
308
|
"""
|
|
292
|
-
c = _cctally()
|
|
293
309
|
a = _REFRESH_USAGE_ANSI if color else {k: "" for k in _REFRESH_USAGE_ANSI}
|
|
294
310
|
|
|
295
311
|
seven = payload["seven_day"]
|
|
296
312
|
seven_pct = seven["used_percent"]
|
|
297
313
|
seven_resets = seven.get("resets_at_epoch")
|
|
298
314
|
if seven_resets is not None:
|
|
299
|
-
seven_ttl =
|
|
315
|
+
seven_ttl = _format_short_duration(seven_resets - now_epoch)
|
|
300
316
|
seven_seg = (
|
|
301
317
|
f"{a['yellow']}7d {seven_pct:.0f}%{a['reset']}"
|
|
302
318
|
f" {a['orange']}(in {seven_ttl}){a['reset']}"
|
|
@@ -309,7 +325,7 @@ def _render_refresh_usage_text(payload: dict, color: bool, now_epoch: int) -> st
|
|
|
309
325
|
five_pct = five["used_percent"]
|
|
310
326
|
five_resets = five.get("resets_at_epoch")
|
|
311
327
|
if five_resets is not None:
|
|
312
|
-
five_ttl =
|
|
328
|
+
five_ttl = _format_short_duration(five_resets - now_epoch)
|
|
313
329
|
five_seg = (
|
|
314
330
|
f" | {a['yellow']}5h {five_pct:.0f}%{a['reset']}"
|
|
315
331
|
f" {a['orange']}(in {five_ttl}){a['reset']}"
|
|
@@ -420,14 +436,13 @@ def _bust_statusline_cache(path: str = _STATUSLINE_OAUTH_CACHE) -> str:
|
|
|
420
436
|
``"absent"`` (file did not exist), ``"error"`` (delete failed for
|
|
421
437
|
a non-FileNotFoundError reason — logged via eprint, does NOT raise).
|
|
422
438
|
"""
|
|
423
|
-
c = _cctally()
|
|
424
439
|
try:
|
|
425
440
|
os.remove(path)
|
|
426
441
|
return "busted"
|
|
427
442
|
except FileNotFoundError:
|
|
428
443
|
return "absent"
|
|
429
444
|
except OSError as exc:
|
|
430
|
-
|
|
445
|
+
eprint(f"refresh-usage: cache-bust failed: {exc}")
|
|
431
446
|
return "error"
|
|
432
447
|
|
|
433
448
|
|
|
@@ -450,7 +465,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
|
|
|
450
465
|
quiet = bool(getattr(args, "quiet", False))
|
|
451
466
|
|
|
452
467
|
if snap is None:
|
|
453
|
-
|
|
468
|
+
eprint("refresh-usage: rate-limited; no last-known data; "
|
|
454
469
|
"status-line will populate on next CC tick")
|
|
455
470
|
if json_mode:
|
|
456
471
|
print(json.dumps({
|
|
@@ -467,7 +482,7 @@ def _cmd_refresh_usage_handle_rate_limit(args: argparse.Namespace, exc) -> int:
|
|
|
467
482
|
_freshness_label(age_s, cfg) if age_s is not None else "stale"
|
|
468
483
|
)
|
|
469
484
|
|
|
470
|
-
|
|
485
|
+
eprint(f"refresh-usage: rate-limited; using last-known "
|
|
471
486
|
f"(captured {int(age_s) if age_s is not None else '?'}s ago)")
|
|
472
487
|
|
|
473
488
|
if json_mode:
|
|
@@ -550,7 +565,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
550
565
|
# built then a downstream cmd_record_usage call fails).
|
|
551
566
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
552
567
|
seven_resets_iso = seven["resets_at"]
|
|
553
|
-
seven_resets_epoch =
|
|
568
|
+
seven_resets_epoch = _iso_to_epoch(seven_resets_iso)
|
|
554
569
|
except (TypeError, ValueError, KeyError) as exc:
|
|
555
570
|
return _RefreshUsageResult(
|
|
556
571
|
status="parse_failed",
|
|
@@ -566,7 +581,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
566
581
|
try:
|
|
567
582
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
568
583
|
five_resets_iso = five["resets_at"]
|
|
569
|
-
five_resets_epoch =
|
|
584
|
+
five_resets_epoch = _iso_to_epoch(five_resets_iso)
|
|
570
585
|
except (TypeError, ValueError) as exc:
|
|
571
586
|
# 5h is optional - silently degrade rather than fail the command
|
|
572
587
|
# (parity with the previous cmd_refresh_usage behavior; the eprint
|
|
@@ -599,7 +614,7 @@ def _refresh_usage_inproc(timeout_seconds: float = 5.0) -> _RefreshUsageResult:
|
|
|
599
614
|
# (tests/test_refresh_usage_cmd.py:55, test_refresh_usage_inproc.py:18).
|
|
600
615
|
cache_state = c._bust_statusline_cache()
|
|
601
616
|
|
|
602
|
-
fetched_at =
|
|
617
|
+
fetched_at = now_utc_iso()
|
|
603
618
|
fresh_envelope = {
|
|
604
619
|
"label": "fresh",
|
|
605
620
|
"captured_at": fetched_at,
|
|
@@ -652,7 +667,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
652
667
|
# Emit BEFORE rendering so stderr flushes consistently for harnesses
|
|
653
668
|
# that grep across both streams.
|
|
654
669
|
for warning in result.warnings:
|
|
655
|
-
|
|
670
|
+
eprint(f"refresh-usage: {warning}")
|
|
656
671
|
payload = result.payload or {}
|
|
657
672
|
if getattr(args, "json", False):
|
|
658
673
|
print(_serialize_refresh_usage_json(payload))
|
|
@@ -668,7 +683,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
668
683
|
args, RefreshUsageRateLimitError(result.reason or "rate limited"))
|
|
669
684
|
|
|
670
685
|
if result.status == "no_oauth_token":
|
|
671
|
-
|
|
686
|
+
eprint("refresh-usage: no OAuth token found "
|
|
672
687
|
"(run 'claude' once to authenticate)")
|
|
673
688
|
return 2
|
|
674
689
|
|
|
@@ -678,9 +693,9 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
678
693
|
# config-error reason string verbatim so we can detect it here.
|
|
679
694
|
reason = result.reason or ""
|
|
680
695
|
if reason.startswith("invalid oauth_usage config:"):
|
|
681
|
-
|
|
696
|
+
eprint(f"cctally: {reason}")
|
|
682
697
|
return 2
|
|
683
|
-
|
|
698
|
+
eprint(f"refresh-usage: OAuth fetch failed: {reason}")
|
|
684
699
|
return 3
|
|
685
700
|
|
|
686
701
|
if result.status == "parse_failed":
|
|
@@ -688,7 +703,7 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
688
703
|
# only seven_day failures (RefreshUsageMalformedError or unparseable
|
|
689
704
|
# field extraction) propagate here. Preserve the original exact
|
|
690
705
|
# error-line shape for bash harnesses that grep stderr.
|
|
691
|
-
|
|
706
|
+
eprint(f"refresh-usage: {result.reason or ''}")
|
|
692
707
|
return 4
|
|
693
708
|
|
|
694
709
|
if result.status == "record_failed":
|
|
@@ -698,13 +713,13 @@ def cmd_refresh_usage(args: argparse.Namespace) -> int:
|
|
|
698
713
|
rc = int(reason.split()[1])
|
|
699
714
|
except (IndexError, ValueError):
|
|
700
715
|
rc = -1
|
|
701
|
-
|
|
716
|
+
eprint(f"refresh-usage: failed to record usage (exit {rc})")
|
|
702
717
|
else:
|
|
703
|
-
|
|
718
|
+
eprint(f"refresh-usage: failed to record usage: {reason}")
|
|
704
719
|
return 5
|
|
705
720
|
|
|
706
721
|
# Defensive: unknown status from _refresh_usage_inproc -> treat as parse error.
|
|
707
|
-
|
|
722
|
+
eprint(f"refresh-usage: unexpected status {result.status!r}")
|
|
708
723
|
return 4
|
|
709
724
|
|
|
710
725
|
|
|
@@ -757,7 +772,7 @@ def _hook_tick_oauth_refresh(
|
|
|
757
772
|
seven = api["seven_day"]
|
|
758
773
|
try:
|
|
759
774
|
seven_pct = c._normalize_percent(float(seven["utilization"]))
|
|
760
|
-
seven_resets_epoch =
|
|
775
|
+
seven_resets_epoch = _iso_to_epoch(seven["resets_at"])
|
|
761
776
|
except (TypeError, ValueError, KeyError):
|
|
762
777
|
return "err(parse)", None
|
|
763
778
|
five = api.get("five_hour") if isinstance(api.get("five_hour"), dict) else None
|
|
@@ -766,7 +781,7 @@ def _hook_tick_oauth_refresh(
|
|
|
766
781
|
if five is not None and "utilization" in five and "resets_at" in five:
|
|
767
782
|
try:
|
|
768
783
|
five_pct = c._normalize_percent(float(five["utilization"]))
|
|
769
|
-
five_resets_epoch =
|
|
784
|
+
five_resets_epoch = _iso_to_epoch(five["resets_at"])
|
|
770
785
|
except (TypeError, ValueError):
|
|
771
786
|
five_pct = None
|
|
772
787
|
five_resets_epoch = None
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -63,6 +63,18 @@ def _cctally():
|
|
|
63
63
|
return sys.modules["cctally"]
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
# === Honest imports from extracted homes ===================================
|
|
67
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
68
|
+
# import from _cctally_core. Path constants (`APP_DIR`,
|
|
69
|
+
# `CLAUDE_SETTINGS_PATH`, `HOOK_TICK_LOG_PATH`) plus the extensive
|
|
70
|
+
# out-of-scope setup-specific helpers (legacy migration, hook surgery,
|
|
71
|
+
# OAuth token, sync_cache, …) stay on the _cctally() accessor.
|
|
72
|
+
from _cctally_core import (
|
|
73
|
+
eprint,
|
|
74
|
+
_command_as_of,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
66
78
|
# ── settings.json hook surgery ─────────────────────────────────────────
|
|
67
79
|
|
|
68
80
|
|
|
@@ -430,7 +442,7 @@ def _legacy_resolve_backup_dir() -> pathlib.Path:
|
|
|
430
442
|
so a re-run never overwrites a prior migration's snapshot.
|
|
431
443
|
"""
|
|
432
444
|
c = _cctally()
|
|
433
|
-
now =
|
|
445
|
+
now = _command_as_of()
|
|
434
446
|
stamp = now.strftime("%Y%m%d-%H%M%S")
|
|
435
447
|
base = pathlib.Path.home() / ".claude" / f"{c._LEGACY_BACKUP_DIR_PREFIX}{stamp}"
|
|
436
448
|
base.mkdir(parents=True, exist_ok=True)
|
|
@@ -614,7 +626,6 @@ def _setup_read_legacy_prompt_input(stream, reprompt: str | None = None) -> bool
|
|
|
614
626
|
us). When None (test default), no reprompt is emitted — useful for unit
|
|
615
627
|
tests that drive `stream` from io.StringIO.
|
|
616
628
|
"""
|
|
617
|
-
eprint = _cctally().eprint
|
|
618
629
|
yes_words = {"y", "yes"}
|
|
619
630
|
no_words = {"n", "no"}
|
|
620
631
|
for attempt in range(3):
|
|
@@ -815,7 +826,7 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
815
826
|
try:
|
|
816
827
|
settings = c._load_claude_settings()
|
|
817
828
|
except c.SetupError as exc:
|
|
818
|
-
|
|
829
|
+
eprint(f"setup: warning: {exc}")
|
|
819
830
|
settings = {}
|
|
820
831
|
hook_counts = _setup_count_hook_entries(settings)
|
|
821
832
|
oauth = _setup_oauth_token_present()
|
|
@@ -911,14 +922,14 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
911
922
|
try:
|
|
912
923
|
settings = c._load_claude_settings()
|
|
913
924
|
except c.SetupError as exc:
|
|
914
|
-
|
|
925
|
+
eprint(f"setup: {exc}")
|
|
915
926
|
return 1
|
|
916
927
|
settings, removed = _settings_merge_uninstall(settings)
|
|
917
928
|
if removed:
|
|
918
929
|
try:
|
|
919
930
|
c._write_claude_settings_atomic(settings)
|
|
920
931
|
except OSError as exc:
|
|
921
|
-
|
|
932
|
+
eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
|
|
922
933
|
return 2
|
|
923
934
|
out.append(f"Removed {removed} hook entries from {c.CLAUDE_SETTINGS_PATH}")
|
|
924
935
|
|
|
@@ -938,7 +949,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
938
949
|
dst.unlink()
|
|
939
950
|
sym_removed += 1
|
|
940
951
|
except OSError as exc:
|
|
941
|
-
|
|
952
|
+
eprint(f"setup: failed to remove {dst}: {exc}")
|
|
942
953
|
out.append(f"Removed {sym_removed} symlinks from {dst_dir}/")
|
|
943
954
|
|
|
944
955
|
legacy = _setup_detect_legacy_snippet()
|
|
@@ -1000,7 +1011,7 @@ def _setup_uninstall(args: argparse.Namespace) -> int:
|
|
|
1000
1011
|
"exit_code": 1,
|
|
1001
1012
|
}, indent=2))
|
|
1002
1013
|
else:
|
|
1003
|
-
|
|
1014
|
+
eprint(f"setup: failed to wipe {c.APP_DIR}: {exc}")
|
|
1004
1015
|
return 1
|
|
1005
1016
|
else:
|
|
1006
1017
|
out.append(
|
|
@@ -1039,7 +1050,7 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1039
1050
|
# against an empty dict simply yields detected=False for entries (files
|
|
1040
1051
|
# detection is independent of settings). Mirror _setup_status's pattern
|
|
1041
1052
|
# so the user sees the same condition that would fail _setup_install.
|
|
1042
|
-
|
|
1053
|
+
eprint(f"setup: warning: {exc}")
|
|
1043
1054
|
settings = {}
|
|
1044
1055
|
detection = _setup_detect_legacy_bespoke_hooks(settings)
|
|
1045
1056
|
sym_results = []
|
|
@@ -1238,7 +1249,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1238
1249
|
|
|
1239
1250
|
claude_dir = pathlib.Path.home() / ".claude"
|
|
1240
1251
|
if not claude_dir.exists():
|
|
1241
|
-
|
|
1252
|
+
eprint(
|
|
1242
1253
|
f"~/.claude/ does not exist. If Claude Code isn't installed yet, "
|
|
1243
1254
|
f"install it first. If it is installed, run `claude` once to "
|
|
1244
1255
|
f"initialize, then re-run cctally setup."
|
|
@@ -1259,7 +1270,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1259
1270
|
try:
|
|
1260
1271
|
settings = c._load_claude_settings()
|
|
1261
1272
|
except c.SetupError as exc:
|
|
1262
|
-
|
|
1273
|
+
eprint(f"setup: {exc}")
|
|
1263
1274
|
return 1
|
|
1264
1275
|
|
|
1265
1276
|
# ── Legacy bespoke hook detection + migration decision (spec §1, §2) ──
|
|
@@ -1304,7 +1315,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1304
1315
|
# still propagates into this code path post-extraction (§5.6 option C).
|
|
1305
1316
|
backup_dir = c._legacy_resolve_backup_dir()
|
|
1306
1317
|
except OSError as exc:
|
|
1307
|
-
|
|
1318
|
+
eprint(f"setup: cannot create migration backup dir: {exc}")
|
|
1308
1319
|
return 1
|
|
1309
1320
|
# Unwire BEFORE the merge so the same atomic write removes legacy
|
|
1310
1321
|
# entries and adds cctally entries (spec §2 step 6).
|
|
@@ -1322,14 +1333,14 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1322
1333
|
try:
|
|
1323
1334
|
_settings_merge_install(settings, abs_path)
|
|
1324
1335
|
except c.SetupError as exc:
|
|
1325
|
-
|
|
1336
|
+
eprint(f"setup: {exc}")
|
|
1326
1337
|
return 1
|
|
1327
1338
|
|
|
1328
1339
|
sym_results = _setup_create_symlinks(repo_root, dst_dir)
|
|
1329
1340
|
failed = [r for r in sym_results if r.status == "failed"]
|
|
1330
1341
|
if failed:
|
|
1331
1342
|
for r in failed:
|
|
1332
|
-
|
|
1343
|
+
eprint(f"setup: symlink {r.name} failed: {r.detail}")
|
|
1333
1344
|
return 1
|
|
1334
1345
|
new_count = sum(1 for r in sym_results if r.status == "created")
|
|
1335
1346
|
same_count = sum(1 for r in sym_results if r.status == "already")
|
|
@@ -1356,7 +1367,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1356
1367
|
try:
|
|
1357
1368
|
c._write_claude_settings_atomic(settings)
|
|
1358
1369
|
except OSError as exc:
|
|
1359
|
-
|
|
1370
|
+
eprint(f"setup: failed to write {c.CLAUDE_SETTINGS_PATH}: {exc}")
|
|
1360
1371
|
return 2
|
|
1361
1372
|
|
|
1362
1373
|
# ── Post-write migration apply (spec §2 steps 6a, 6b) ──
|
|
@@ -1548,7 +1559,6 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1548
1559
|
|
|
1549
1560
|
|
|
1550
1561
|
def cmd_setup(args: argparse.Namespace) -> int:
|
|
1551
|
-
c = _cctally()
|
|
1552
1562
|
# Migration flags are install-mode-only. Reject combinations with
|
|
1553
1563
|
# --status or --uninstall (per spec Section 2 mode×flag matrix). The
|
|
1554
1564
|
# mutex group on the parser already prevents both flags being set
|
|
@@ -1560,7 +1570,7 @@ def cmd_setup(args: argparse.Namespace) -> int:
|
|
|
1560
1570
|
else None
|
|
1561
1571
|
)
|
|
1562
1572
|
if mig_flag and (getattr(args, "status", False) or getattr(args, "uninstall", False)):
|
|
1563
|
-
|
|
1573
|
+
eprint(f"setup: {mig_flag} is install-mode only")
|
|
1564
1574
|
return 2
|
|
1565
1575
|
if getattr(args, "uninstall", False):
|
|
1566
1576
|
return _setup_uninstall(args)
|
|
@@ -30,12 +30,27 @@ def _cctally():
|
|
|
30
30
|
return sys.modules["cctally"]
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# === Honest imports from extracted homes ===================================
|
|
34
|
+
# Spec 2026-05-17-cctally-core-kernel-extraction.md §3.3: kernel symbols
|
|
35
|
+
# import from _cctally_core. `load_config` stays on the _cctally()
|
|
36
|
+
# accessor per spec §3.5 monkeypatch carve-out. Z-high helpers
|
|
37
|
+
# (`compute_week_cost`, `insert_cost_snapshot`) and out-of-scope
|
|
38
|
+
# (`pick_week_selection`) stay on the accessor per spec §3.7.
|
|
39
|
+
from _cctally_core import (
|
|
40
|
+
get_week_start_name,
|
|
41
|
+
open_db,
|
|
42
|
+
format_local_iso,
|
|
43
|
+
make_week_ref,
|
|
44
|
+
get_latest_usage_for_week,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
33
48
|
def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
34
49
|
c = _cctally()
|
|
35
50
|
config = c.load_config()
|
|
36
|
-
week_start_name =
|
|
51
|
+
week_start_name = get_week_start_name(config, args.week_start_name)
|
|
37
52
|
|
|
38
|
-
conn =
|
|
53
|
+
conn = open_db()
|
|
39
54
|
try:
|
|
40
55
|
selection = c.pick_week_selection(
|
|
41
56
|
conn,
|
|
@@ -54,8 +69,8 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
54
69
|
start_iso_override=selection.start_iso_override,
|
|
55
70
|
end_iso_override=selection.end_iso_override,
|
|
56
71
|
)
|
|
57
|
-
week_start_at = selection.start_iso_override or
|
|
58
|
-
week_end_at = selection.end_iso_override or
|
|
72
|
+
week_start_at = selection.start_iso_override or format_local_iso(week_start, end_of_day=False)
|
|
73
|
+
week_end_at = selection.end_iso_override or format_local_iso(week_end, end_of_day=True)
|
|
59
74
|
insert_id = c.insert_cost_snapshot(
|
|
60
75
|
conn,
|
|
61
76
|
week_start=week_start,
|
|
@@ -69,13 +84,13 @@ def cmd_sync_week(args: argparse.Namespace) -> int:
|
|
|
69
84
|
project=args.project,
|
|
70
85
|
)
|
|
71
86
|
|
|
72
|
-
week_ref =
|
|
87
|
+
week_ref = make_week_ref(
|
|
73
88
|
week_start_date=week_start.isoformat(),
|
|
74
89
|
week_end_date=week_end.isoformat(),
|
|
75
90
|
week_start_at=week_start_at,
|
|
76
91
|
week_end_at=week_end_at,
|
|
77
92
|
)
|
|
78
|
-
usage_row =
|
|
93
|
+
usage_row = get_latest_usage_for_week(conn, week_ref)
|
|
79
94
|
weekly_percent = float(usage_row["weekly_percent"]) if usage_row else None
|
|
80
95
|
dollars_per_percent = (
|
|
81
96
|
result.cost_usd / weekly_percent if weekly_percent and weekly_percent > 0 else None
|