cctally 1.11.0 → 1.12.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 +52 -0
- package/bin/_cctally_alerts.py +14 -18
- package/bin/_cctally_cache.py +366 -140
- package/bin/_cctally_config.py +31 -22
- package/bin/_cctally_core.py +145 -8
- package/bin/_cctally_dashboard.py +2 -1
- package/bin/_cctally_db.py +1696 -35
- package/bin/_cctally_record.py +27 -27
- package/bin/_cctally_setup.py +39 -27
- package/bin/_cctally_tui.py +2 -1
- package/bin/_cctally_update.py +41 -33
- package/bin/_lib_changelog.py +3 -1
- package/bin/_lib_jsonl.py +80 -16
- package/bin/_lib_share_templates.py +31 -13
- package/bin/cctally +112 -109
- package/package.json +1 -1
package/bin/_lib_jsonl.py
CHANGED
|
@@ -59,14 +59,64 @@ class CodexEntry:
|
|
|
59
59
|
source_path: str
|
|
60
60
|
|
|
61
61
|
|
|
62
|
+
def _entry_token_total(entry: "UsageEntry") -> int:
|
|
63
|
+
"""Sum of the four billed token fields. Mirrors ccusage's
|
|
64
|
+
`usage_token_total` in rust/crates/ccusage/src/claude_loader.rs:516."""
|
|
65
|
+
u = entry.usage
|
|
66
|
+
return (
|
|
67
|
+
int(u.get("input_tokens", 0) or 0)
|
|
68
|
+
+ int(u.get("output_tokens", 0) or 0)
|
|
69
|
+
+ int(u.get("cache_creation_input_tokens", 0) or 0)
|
|
70
|
+
+ int(u.get("cache_read_input_tokens", 0) or 0)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _should_replace(
|
|
75
|
+
candidate: "UsageEntry", existing: "UsageEntry"
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""Port of ccusage's `should_replace_deduped_entry` in
|
|
78
|
+
rust/crates/ccusage/src/claude_loader.rs:531. Higher token total wins;
|
|
79
|
+
on equal totals, the row with `speed` set (non-null) wins (the post-stream
|
|
80
|
+
finalization row carries `speed`; streaming intermediates don't).
|
|
81
|
+
|
|
82
|
+
The `usage.get("speed") is not None` check matches the SQL UPDATE WHERE
|
|
83
|
+
clause's `json_extract(..., '$.speed') IS NOT NULL` in `sync_cache`'s
|
|
84
|
+
INSERT … ON CONFLICT … DO UPDATE, keeping the direct-parse fallback and
|
|
85
|
+
cache-ingest paths in lockstep on the rare-but-possible "explicit JSON
|
|
86
|
+
null" payload.
|
|
87
|
+
"""
|
|
88
|
+
c_total = _entry_token_total(candidate)
|
|
89
|
+
e_total = _entry_token_total(existing)
|
|
90
|
+
if c_total != e_total:
|
|
91
|
+
return c_total > e_total
|
|
92
|
+
return (candidate.usage.get("speed") is not None
|
|
93
|
+
and existing.usage.get("speed") is None)
|
|
94
|
+
|
|
95
|
+
|
|
62
96
|
def _parse_usage_entries(
|
|
63
97
|
jsonl_path: pathlib.Path,
|
|
64
98
|
range_start: dt.datetime,
|
|
65
99
|
range_end: dt.datetime,
|
|
66
|
-
|
|
100
|
+
*,
|
|
101
|
+
dedupe_map: "dict[str, UsageEntry]",
|
|
67
102
|
) -> list[UsageEntry]:
|
|
68
|
-
"""Parse
|
|
69
|
-
|
|
103
|
+
"""Parse one JSONL file's assistant entries within [range_start, range_end].
|
|
104
|
+
|
|
105
|
+
Dedup contract (matches ccusage's `push_deduped_entry`):
|
|
106
|
+
- Entries with non-null (msg_id, req_id) go into `dedupe_map`; if a key
|
|
107
|
+
already maps to an entry, replace iff `_should_replace(candidate, existing)`.
|
|
108
|
+
- Entries with null msg_id or null req_id (rare in modern Claude Code,
|
|
109
|
+
but possible on synthetic / legacy emissions) skip the dedup map and
|
|
110
|
+
land in a separate list — partial UNIQUE index on the cache mirrors
|
|
111
|
+
this behavior.
|
|
112
|
+
- `<synthetic>` model rows are dropped entirely (matches ccusage's
|
|
113
|
+
claude_loader.rs:454).
|
|
114
|
+
|
|
115
|
+
Caller is responsible for sorting the returned list by timestamp if
|
|
116
|
+
needed; `_collect_entries_direct` does this once across all files
|
|
117
|
+
after flattening `dedupe_map.values()`.
|
|
118
|
+
"""
|
|
119
|
+
no_key_entries: list[UsageEntry] = []
|
|
70
120
|
try:
|
|
71
121
|
with open(jsonl_path, "r", encoding="utf-8", errors="replace") as fh:
|
|
72
122
|
for line in fh:
|
|
@@ -96,6 +146,11 @@ def _parse_usage_entries(
|
|
|
96
146
|
model = msg.get("model") or obj.get("model")
|
|
97
147
|
if not isinstance(model, str) or not model.strip():
|
|
98
148
|
continue
|
|
149
|
+
model = model.strip()
|
|
150
|
+
if model == "<synthetic>":
|
|
151
|
+
# Matches ccusage's claude_loader.rs:454 — synthetic
|
|
152
|
+
# placeholder rows carry no billable usage.
|
|
153
|
+
continue
|
|
99
154
|
|
|
100
155
|
try:
|
|
101
156
|
ts = dt.datetime.fromisoformat(
|
|
@@ -109,16 +164,8 @@ def _parse_usage_entries(
|
|
|
109
164
|
if ts < range_start or ts > range_end:
|
|
110
165
|
continue
|
|
111
166
|
|
|
112
|
-
# Deduplicate by message.id + requestId (same as ccusage)
|
|
113
167
|
msg_id = msg.get("id")
|
|
114
168
|
req_id = obj.get("requestId")
|
|
115
|
-
if msg_id is not None and req_id is not None:
|
|
116
|
-
entry_hash = f"{msg_id}:{req_id}"
|
|
117
|
-
if seen_hashes is not None:
|
|
118
|
-
if entry_hash in seen_hashes:
|
|
119
|
-
continue
|
|
120
|
-
seen_hashes.add(entry_hash)
|
|
121
|
-
|
|
122
169
|
cost_usd_raw = obj.get("costUSD")
|
|
123
170
|
cost_usd = (
|
|
124
171
|
float(cost_usd_raw)
|
|
@@ -126,16 +173,26 @@ def _parse_usage_entries(
|
|
|
126
173
|
else None
|
|
127
174
|
)
|
|
128
175
|
|
|
129
|
-
|
|
176
|
+
entry = UsageEntry(
|
|
130
177
|
timestamp=ts,
|
|
131
|
-
model=model
|
|
178
|
+
model=model,
|
|
132
179
|
usage=usage,
|
|
133
180
|
cost_usd=cost_usd,
|
|
134
|
-
)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
if msg_id is None or req_id is None:
|
|
184
|
+
no_key_entries.append(entry)
|
|
185
|
+
continue
|
|
186
|
+
key = f"{msg_id}:{req_id}"
|
|
187
|
+
existing = dedupe_map.get(key)
|
|
188
|
+
if existing is None or _should_replace(entry, existing):
|
|
189
|
+
dedupe_map[key] = entry
|
|
135
190
|
except OSError as exc:
|
|
136
191
|
_eprint(f"[cost] could not read {jsonl_path}: {exc}")
|
|
137
192
|
|
|
138
|
-
|
|
193
|
+
# The function returns ONLY this file's no-key entries; the caller
|
|
194
|
+
# flattens `dedupe_map.values()` once at the end across all files.
|
|
195
|
+
return no_key_entries
|
|
139
196
|
|
|
140
197
|
|
|
141
198
|
def _iter_jsonl_entries_with_offsets(fh):
|
|
@@ -185,6 +242,13 @@ def _iter_jsonl_entries_with_offsets(fh):
|
|
|
185
242
|
model = msg.get("model") or obj.get("model")
|
|
186
243
|
if not isinstance(model, str) or not model.strip():
|
|
187
244
|
continue
|
|
245
|
+
model = model.strip()
|
|
246
|
+
if model == "<synthetic>":
|
|
247
|
+
# Matches ccusage's claude_loader.rs:454. Filtered at the
|
|
248
|
+
# iterator level so the cache ingest path can't accidentally
|
|
249
|
+
# store these rows even if a downstream loop forgets to
|
|
250
|
+
# double-check (see `sync_cache` in _cctally_cache.py).
|
|
251
|
+
continue
|
|
188
252
|
|
|
189
253
|
try:
|
|
190
254
|
ts = dt.datetime.fromisoformat(ts_raw.strip().replace("Z", "+00:00"))
|
|
@@ -202,7 +266,7 @@ def _iter_jsonl_entries_with_offsets(fh):
|
|
|
202
266
|
offset,
|
|
203
267
|
UsageEntry(
|
|
204
268
|
timestamp=ts,
|
|
205
|
-
model=model
|
|
269
|
+
model=model,
|
|
206
270
|
usage=usage,
|
|
207
271
|
cost_usd=cost_usd,
|
|
208
272
|
),
|
|
@@ -21,6 +21,18 @@ from collections.abc import Callable, Mapping
|
|
|
21
21
|
from dataclasses import dataclass
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
# NOTE: ``_cctally_core`` is imported lazily inside ``_release_version()``
|
|
25
|
+
# (the sole consumer in this module) so that loading this file directly
|
|
26
|
+
# via ``importlib.util.spec_from_file_location`` — the established
|
|
27
|
+
# pattern used by ``_render_share_v2_fixture.py``,
|
|
28
|
+
# ``_compose_share_v2_fixture.py``, and ``tests/test_lib_share_templates.py``
|
|
29
|
+
# — does NOT depend on ``bin/`` being on ``sys.path`` at module-import
|
|
30
|
+
# time. A module-top ``import _cctally_core`` would raise
|
|
31
|
+
# ``ModuleNotFoundError`` for any standalone loader that hasn't already
|
|
32
|
+
# arranged for ``_cctally_core`` to be importable, regressing the
|
|
33
|
+
# parallel-loader pattern this module uses for ``_lib_share`` itself
|
|
34
|
+
# (see ``_import_share_lib`` below).
|
|
35
|
+
|
|
24
36
|
|
|
25
37
|
# --- Panel set ---
|
|
26
38
|
#
|
|
@@ -288,22 +300,28 @@ def _utc_now() -> _dt.datetime:
|
|
|
288
300
|
def _release_version() -> str:
|
|
289
301
|
"""Read CHANGELOG-stamped latest version.
|
|
290
302
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
303
|
+
Resolves through ``_cctally_core.CHANGELOG_PATH`` so the kernel's
|
|
304
|
+
env override (``CCTALLY_TEST_CHANGELOG_PATH``) and any test-side
|
|
305
|
+
``monkeypatch.setattr(_cctally_core, "CHANGELOG_PATH", ...)`` both
|
|
306
|
+
propagate transparently. Falls back to ``"dev"`` when CHANGELOG is
|
|
307
|
+
unreadable or has no stamped release entry yet (pre-release dev
|
|
308
|
+
builds).
|
|
294
309
|
|
|
295
|
-
Parallel to
|
|
296
|
-
(re-exported on
|
|
297
|
-
|
|
310
|
+
Parallel to ``_lib_changelog._read_latest_changelog_version``
|
|
311
|
+
(re-exported on ``bin/cctally`` under the historical
|
|
312
|
+
``_release_read_latest_release_version`` name) — intentionally
|
|
298
313
|
duplicated so the template module stays free of any
|
|
299
|
-
|
|
314
|
+
``bin/cctally`` import. If CHANGELOG header format changes, update both.
|
|
315
|
+
|
|
316
|
+
``_cctally_core`` is imported lazily here (rather than at module top)
|
|
317
|
+
so that standalone loaders — see the note at the top of this file
|
|
318
|
+
and the ``_import_share_lib`` pattern below — can exec this module
|
|
319
|
+
without ``bin/`` already being on ``sys.path``. By the time
|
|
320
|
+
``_release_version()`` is actually called, the caller (CLI boot,
|
|
321
|
+
dashboard, or fixture driver) has resolved the sibling layout.
|
|
300
322
|
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
if p:
|
|
304
|
-
path = Path(p)
|
|
305
|
-
else:
|
|
306
|
-
path = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
323
|
+
import _cctally_core # noqa: PLC0415 — lazy by design (see module-top NOTE)
|
|
324
|
+
path = _cctally_core.CHANGELOG_PATH
|
|
307
325
|
try:
|
|
308
326
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
309
327
|
if line.startswith("## [") and "Unreleased" not in line:
|
package/bin/cctally
CHANGED
|
@@ -135,15 +135,8 @@ def _load_sibling(name: str):
|
|
|
135
135
|
_THIS_MODULE = sys.modules.get(__name__) or sys.modules.get("cctally")
|
|
136
136
|
|
|
137
137
|
|
|
138
|
-
|
|
139
|
-
LEGACY_APP_DIR = pathlib.Path.home() / ".local" / "share" / "ccusage-subscription"
|
|
140
|
-
# Hook-tick runtime artifacts (Section 1 of onboarding spec).
|
|
141
|
-
HOOK_TICK_LOG_DIR = APP_DIR / "logs"
|
|
142
|
-
HOOK_TICK_LOG_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log"
|
|
143
|
-
HOOK_TICK_LOG_ROTATED_PATH = HOOK_TICK_LOG_DIR / "hook-tick.log.1"
|
|
138
|
+
# Hook-tick non-path constants (Section 1 of onboarding spec).
|
|
144
139
|
HOOK_TICK_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB
|
|
145
|
-
HOOK_TICK_THROTTLE_PATH = APP_DIR / "hook-tick.last-fetch"
|
|
146
|
-
HOOK_TICK_THROTTLE_LOCK_PATH = APP_DIR / "hook-tick.last-fetch.lock"
|
|
147
140
|
HOOK_TICK_DEFAULT_THROTTLE_SECONDS = 30.0
|
|
148
141
|
|
|
149
142
|
# User-facing executables symlinked by `cctally setup` (Section 2.2 of spec).
|
|
@@ -167,19 +160,6 @@ SETUP_HOOK_EVENTS = ("PostToolBatch", "Stop", "SubagentStop")
|
|
|
167
160
|
|
|
168
161
|
# === Release automation (issue #24) ===
|
|
169
162
|
|
|
170
|
-
_CHANGELOG_OVERRIDE = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
|
|
171
|
-
if _CHANGELOG_OVERRIDE:
|
|
172
|
-
# Fixture-stability hook for `bin/cctally-share-test` — points
|
|
173
|
-
# `_share_resolve_version()` (and the broader release machinery) at a
|
|
174
|
-
# per-scenario CHANGELOG so version stamping in goldens stays
|
|
175
|
-
# deterministic regardless of the in-tree CHANGELOG state. Mirrors the
|
|
176
|
-
# `CCTALLY_AS_OF` env-only precedent: not in --help, no docstring
|
|
177
|
-
# surface; consumed exclusively by harness wrappers.
|
|
178
|
-
CHANGELOG_PATH = pathlib.Path(_CHANGELOG_OVERRIDE)
|
|
179
|
-
else:
|
|
180
|
-
CHANGELOG_PATH = pathlib.Path(__file__).resolve().parent.parent / "CHANGELOG.md"
|
|
181
|
-
|
|
182
|
-
|
|
183
163
|
# Public mirror repo identity (the GitHub `<owner>/<repo>` slug). Used by
|
|
184
164
|
# Phase 3 (mirror push), Phase 4 (`gh release create` / view), and the
|
|
185
165
|
# fallback printout. Distinct from `.mirror-allowlist`, which classifies
|
|
@@ -230,6 +210,45 @@ make_week_ref = _cctally_core.make_week_ref
|
|
|
230
210
|
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
231
211
|
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
232
212
|
|
|
213
|
+
# === Path constants — re-exported from _cctally_core ================
|
|
214
|
+
#
|
|
215
|
+
# Promoted 2026-05-22 (docs/superpowers/specs/2026-05-22-cctally-core-data-globals.md).
|
|
216
|
+
# `_cctally_core` is the single source of truth and the only legal
|
|
217
|
+
# monkeypatch target. These re-exports exist for ad-hoc REPL / scripts
|
|
218
|
+
# that read `cctally.APP_DIR` directly.
|
|
219
|
+
|
|
220
|
+
APP_DIR = _cctally_core.APP_DIR
|
|
221
|
+
LEGACY_APP_DIR = _cctally_core.LEGACY_APP_DIR
|
|
222
|
+
LOG_DIR = _cctally_core.LOG_DIR
|
|
223
|
+
|
|
224
|
+
DB_PATH = _cctally_core.DB_PATH
|
|
225
|
+
CACHE_DB_PATH = _cctally_core.CACHE_DB_PATH
|
|
226
|
+
|
|
227
|
+
CACHE_LOCK_PATH = _cctally_core.CACHE_LOCK_PATH
|
|
228
|
+
CACHE_LOCK_CODEX_PATH = _cctally_core.CACHE_LOCK_CODEX_PATH
|
|
229
|
+
CONFIG_LOCK_PATH = _cctally_core.CONFIG_LOCK_PATH
|
|
230
|
+
|
|
231
|
+
CONFIG_PATH = _cctally_core.CONFIG_PATH
|
|
232
|
+
|
|
233
|
+
MIGRATION_ERROR_LOG_PATH = _cctally_core.MIGRATION_ERROR_LOG_PATH
|
|
234
|
+
|
|
235
|
+
CHANGELOG_PATH = _cctally_core.CHANGELOG_PATH
|
|
236
|
+
|
|
237
|
+
HOOK_TICK_LOG_DIR = _cctally_core.HOOK_TICK_LOG_DIR
|
|
238
|
+
HOOK_TICK_LOG_PATH = _cctally_core.HOOK_TICK_LOG_PATH
|
|
239
|
+
HOOK_TICK_LOG_ROTATED_PATH = _cctally_core.HOOK_TICK_LOG_ROTATED_PATH
|
|
240
|
+
HOOK_TICK_THROTTLE_PATH = _cctally_core.HOOK_TICK_THROTTLE_PATH
|
|
241
|
+
HOOK_TICK_THROTTLE_LOCK_PATH = _cctally_core.HOOK_TICK_THROTTLE_LOCK_PATH
|
|
242
|
+
|
|
243
|
+
UPDATE_STATE_PATH = _cctally_core.UPDATE_STATE_PATH
|
|
244
|
+
UPDATE_SUPPRESS_PATH = _cctally_core.UPDATE_SUPPRESS_PATH
|
|
245
|
+
UPDATE_LOCK_PATH = _cctally_core.UPDATE_LOCK_PATH
|
|
246
|
+
UPDATE_LOG_PATH = _cctally_core.UPDATE_LOG_PATH
|
|
247
|
+
UPDATE_LOG_ROTATED_PATH = _cctally_core.UPDATE_LOG_ROTATED_PATH
|
|
248
|
+
UPDATE_CHECK_LAST_FETCH_PATH = _cctally_core.UPDATE_CHECK_LAST_FETCH_PATH
|
|
249
|
+
|
|
250
|
+
CLAUDE_SETTINGS_PATH = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
251
|
+
|
|
233
252
|
_lib_semver = _load_sibling("_lib_semver")
|
|
234
253
|
_SEMVER_NUM = _lib_semver._SEMVER_NUM
|
|
235
254
|
_SEMVER_RE = _lib_semver._SEMVER_RE
|
|
@@ -648,11 +667,15 @@ _saved_dict_from_usage_row = _cctally_record._saved_dict_from_usage_row
|
|
|
648
667
|
# the ``c = _cctally()`` accessor at call time so
|
|
649
668
|
# ``setitem(ns, "_do_update_check", mock)`` propagates into
|
|
650
669
|
# ``cmd_update_check_internal`` / ``_DashboardUpdateCheckThread.run``.
|
|
651
|
-
# Path constants
|
|
652
|
-
#
|
|
653
|
-
#
|
|
654
|
-
#
|
|
655
|
-
#
|
|
670
|
+
# Path constants (UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH,
|
|
671
|
+
# UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH,
|
|
672
|
+
# UPDATE_CHECK_LAST_FETCH_PATH) were promoted to _cctally_core
|
|
673
|
+
# 2026-05-22 (#84). Moved bodies in _cctally_update read them via
|
|
674
|
+
# call-time ``_cctally_core.UPDATE_STATE_PATH`` etc.; tests patch via
|
|
675
|
+
# ``monkeypatch.setattr(_cctally_core, "X", v)`` (the conftest
|
|
676
|
+
# ``redirect_paths()`` helper covers the full set). The legacy
|
|
677
|
+
# ``setitem(ns, …)`` pattern is forbidden by
|
|
678
|
+
# ``test_no_old_style_test_patches_for_promoted_globals``.
|
|
656
679
|
_cctally_update = _load_sibling("_cctally_update")
|
|
657
680
|
UpdateError = _cctally_update.UpdateError
|
|
658
681
|
UpdateValidationError = _cctally_update.UpdateValidationError
|
|
@@ -822,64 +845,26 @@ LEGACY_STATUSLINE_PATHS = (
|
|
|
822
845
|
)
|
|
823
846
|
LEGACY_STATUSLINE_NEEDLE = "cctally record-usage"
|
|
824
847
|
|
|
825
|
-
DB_PATH = APP_DIR / "stats.db"
|
|
826
|
-
CACHE_DB_PATH = APP_DIR / "cache.db"
|
|
827
|
-
CACHE_LOCK_PATH = APP_DIR / "cache.db.lock"
|
|
828
|
-
CACHE_LOCK_CODEX_PATH = APP_DIR / "cache.db.codex.lock"
|
|
829
848
|
CODEX_SESSIONS_DIR = pathlib.Path.home() / ".codex" / "sessions"
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
LOG_DIR
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
#
|
|
836
|
-
#
|
|
837
|
-
#
|
|
838
|
-
#
|
|
839
|
-
#
|
|
840
|
-
# `
|
|
841
|
-
#
|
|
842
|
-
#
|
|
843
|
-
#
|
|
844
|
-
# fixture also work because `redirect_paths` already patches the
|
|
845
|
-
# cctally-side path constants AND will additionally need to patch the
|
|
846
|
-
# sibling-side copy below — that's why we expose this seed: tests can
|
|
847
|
-
# discover the sibling via `cctally_module._cctally_db` and patch via
|
|
848
|
-
# `monkeypatch.setitem(_cctally_db.__dict__, "X", tmp)` if needed.
|
|
849
|
-
_cctally_db.LOG_DIR = LOG_DIR
|
|
850
|
-
_cctally_db.MIGRATION_ERROR_LOG_PATH = MIGRATION_ERROR_LOG_PATH
|
|
851
|
-
_cctally_db.DB_PATH = DB_PATH
|
|
852
|
-
_cctally_db.CACHE_DB_PATH = CACHE_DB_PATH
|
|
853
|
-
|
|
854
|
-
# Note: `_cctally_cache.py` does NOT need a path-constant seed block
|
|
855
|
-
# parallel to `_cctally_db` above — its moved bodies reach
|
|
856
|
-
# `APP_DIR` / `CACHE_DB_PATH` / `CACHE_LOCK_PATH` /
|
|
857
|
-
# `CACHE_LOCK_CODEX_PATH` / `CODEX_SESSIONS_DIR` via the
|
|
858
|
-
# `c = _cctally()` call-time accessor (spec §5.5). That defers every
|
|
859
|
-
# read until call-time `sys.modules['cctally'].X`, so
|
|
860
|
-
# `monkeypatch.setitem(ns, "CACHE_DB_PATH", tmp)` test patches and
|
|
861
|
-
# conftest `redirect_paths` HOME redirects propagate without
|
|
862
|
-
# touching any sibling-side seeded copy. We chose `c.X` over the
|
|
863
|
-
# `_cctally_db`-style seed block here because cache test sites are
|
|
864
|
-
# widely scattered (record-usage tick, dashboard panels, share
|
|
865
|
-
# render kernel, block tests, every JSONL-reading subcommand
|
|
866
|
-
# fixture) — inline patches against ns by setitem are the
|
|
867
|
-
# established idiom, and the c.X path keeps them all working with
|
|
868
|
-
# zero test-file edits.
|
|
849
|
+
|
|
850
|
+
# Note: `_cctally_db` reads its four path constants
|
|
851
|
+
# (`LOG_DIR`/`MIGRATION_ERROR_LOG_PATH`/`DB_PATH`/`CACHE_DB_PATH`) via
|
|
852
|
+
# `_cctally_core.X` at call time — the canonical sibling pattern after
|
|
853
|
+
# the data-globals promotion (2026-05-22, issue #84). The previous
|
|
854
|
+
# eager-seed block here is no longer needed: `_cctally_core` is the
|
|
855
|
+
# single source of truth, and `monkeypatch.setattr(_cctally_core, "X",
|
|
856
|
+
# v)` propagates into every reader without a sibling-side mirror.
|
|
857
|
+
#
|
|
858
|
+
# Note: `_cctally_cache.py` reaches `APP_DIR` / `CACHE_DB_PATH` /
|
|
859
|
+
# `CACHE_LOCK_PATH` / `CACHE_LOCK_CODEX_PATH` via call-time
|
|
860
|
+
# `_cctally_core.X` (promoted 2026-05-22, #84). Only `CODEX_SESSIONS_DIR`
|
|
861
|
+
# is out of scope for #84 — that one is still read via the
|
|
862
|
+
# `c = _cctally()` accessor and lives in this file.
|
|
869
863
|
|
|
870
864
|
# === Update subcommand (Section 1 of update-subcommand spec) ===
|
|
871
|
-
#
|
|
872
|
-
#
|
|
873
|
-
# tests/conftest.py:redirect_paths can monkeypatch them, and so later
|
|
874
|
-
# tasks (install detection, version-check pipeline, dashboard worker)
|
|
875
|
-
# don't have to revisit constant placement.
|
|
876
|
-
UPDATE_STATE_PATH = APP_DIR / "update-state.json"
|
|
877
|
-
UPDATE_SUPPRESS_PATH = APP_DIR / "update-suppress.json"
|
|
878
|
-
UPDATE_LOCK_PATH = APP_DIR / "update.lock"
|
|
879
|
-
UPDATE_LOG_PATH = APP_DIR / "update.log"
|
|
880
|
-
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
865
|
+
# Non-path constants for the `cctally update` feature. Path constants
|
|
866
|
+
# now live in bin/_cctally_core.py (promoted 2026-05-22; see #84).
|
|
881
867
|
UPDATE_LOG_ROTATE_BYTES = 1024 * 1024 # 1 MB; spec §1.5
|
|
882
|
-
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
883
868
|
|
|
884
869
|
UPDATE_NPM_REGISTRY_URL = os.environ.get(
|
|
885
870
|
"CCTALLY_TEST_UPDATE_NPM_URL",
|
|
@@ -904,14 +889,14 @@ def _migrate_legacy_data_dir() -> None:
|
|
|
904
889
|
Removable in a future major version once early users have been on
|
|
905
890
|
cctally long enough that the legacy dir is gone everywhere.
|
|
906
891
|
"""
|
|
907
|
-
if APP_DIR.exists():
|
|
892
|
+
if _cctally_core.APP_DIR.exists():
|
|
908
893
|
return # already migrated, or fresh install at the new path
|
|
909
|
-
if not LEGACY_APP_DIR.exists():
|
|
894
|
+
if not _cctally_core.LEGACY_APP_DIR.exists():
|
|
910
895
|
return # fresh install, no legacy data
|
|
911
|
-
APP_DIR.parent.mkdir(parents=True, exist_ok=True)
|
|
912
|
-
os.rename(LEGACY_APP_DIR, APP_DIR)
|
|
896
|
+
_cctally_core.APP_DIR.parent.mkdir(parents=True, exist_ok=True)
|
|
897
|
+
os.rename(_cctally_core.LEGACY_APP_DIR, _cctally_core.APP_DIR)
|
|
913
898
|
print(
|
|
914
|
-
f"cctally: migrated data dir {LEGACY_APP_DIR} -> {APP_DIR}",
|
|
899
|
+
f"cctally: migrated data dir {_cctally_core.LEGACY_APP_DIR} -> {_cctally_core.APP_DIR}",
|
|
915
900
|
file=sys.stderr,
|
|
916
901
|
)
|
|
917
902
|
|
|
@@ -7723,11 +7708,17 @@ class SetupError(RuntimeError):
|
|
|
7723
7708
|
"""Raised when setup hits a hard prerequisite failure (Section 2 of spec)."""
|
|
7724
7709
|
|
|
7725
7710
|
|
|
7726
|
-
|
|
7727
|
-
|
|
7711
|
+
def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
|
|
7712
|
+
"""Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
|
|
7728
7713
|
|
|
7729
|
-
|
|
7730
|
-
|
|
7714
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7715
|
+
TIME when omitted, so ``monkeypatch.setattr(_cctally_core,
|
|
7716
|
+
"CLAUDE_SETTINGS_PATH", tmp)`` propagates without needing to swap
|
|
7717
|
+
out this callable. Capturing the default at def-time would silently
|
|
7718
|
+
pin the maintainer's real ``~/.claude/settings.json``.
|
|
7719
|
+
"""
|
|
7720
|
+
if path is None:
|
|
7721
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
7731
7722
|
if not path.exists():
|
|
7732
7723
|
return {}
|
|
7733
7724
|
raw = path.read_text(encoding="utf-8")
|
|
@@ -7744,8 +7735,14 @@ def _load_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> dict:
|
|
|
7744
7735
|
return data
|
|
7745
7736
|
|
|
7746
7737
|
|
|
7747
|
-
def _backup_claude_settings(path: pathlib.Path =
|
|
7748
|
-
"""Best-effort daily backup; return backup path or None.
|
|
7738
|
+
def _backup_claude_settings(path: pathlib.Path | None = None) -> pathlib.Path | None:
|
|
7739
|
+
"""Best-effort daily backup; return backup path or None.
|
|
7740
|
+
|
|
7741
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7742
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
7743
|
+
"""
|
|
7744
|
+
if path is None:
|
|
7745
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
7749
7746
|
if not path.exists():
|
|
7750
7747
|
return None
|
|
7751
7748
|
today = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
|
|
@@ -7761,9 +7758,15 @@ def _backup_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> pathli
|
|
|
7761
7758
|
|
|
7762
7759
|
|
|
7763
7760
|
def _write_claude_settings_atomic(
|
|
7764
|
-
settings: dict, path: pathlib.Path =
|
|
7761
|
+
settings: dict, path: pathlib.Path | None = None
|
|
7765
7762
|
) -> None:
|
|
7766
|
-
"""Atomic write with 2-space indent, trailing newline.
|
|
7763
|
+
"""Atomic write with 2-space indent, trailing newline.
|
|
7764
|
+
|
|
7765
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
7766
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
7767
|
+
"""
|
|
7768
|
+
if path is None:
|
|
7769
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
7767
7770
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
7768
7771
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
7769
7772
|
tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
@@ -8415,19 +8418,19 @@ def doctor_gather_state(
|
|
|
8415
8418
|
|
|
8416
8419
|
# ── DB ───────────────────────────────────────────────────────────
|
|
8417
8420
|
try:
|
|
8418
|
-
stats_db_status = _db_status_for(DB_PATH, _STATS_MIGRATIONS, "stats.db")
|
|
8419
|
-
if not DB_PATH.exists():
|
|
8421
|
+
stats_db_status = _db_status_for(_cctally_core.DB_PATH, _STATS_MIGRATIONS, "stats.db")
|
|
8422
|
+
if not _cctally_core.DB_PATH.exists():
|
|
8420
8423
|
stats_db_status["_file_exists"] = False
|
|
8421
8424
|
except sqlite3.Error as exc:
|
|
8422
|
-
stats_db_status = {"path": str(DB_PATH), "user_version": 0,
|
|
8425
|
+
stats_db_status = {"path": str(_cctally_core.DB_PATH), "user_version": 0,
|
|
8423
8426
|
"registry_size": len(_STATS_MIGRATIONS),
|
|
8424
8427
|
"migrations": [], "_open_error": str(exc)}
|
|
8425
8428
|
try:
|
|
8426
|
-
cache_db_status = _db_status_for(CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
8427
|
-
if not CACHE_DB_PATH.exists():
|
|
8429
|
+
cache_db_status = _db_status_for(_cctally_core.CACHE_DB_PATH, _CACHE_MIGRATIONS, "cache.db")
|
|
8430
|
+
if not _cctally_core.CACHE_DB_PATH.exists():
|
|
8428
8431
|
cache_db_status["_file_exists"] = False
|
|
8429
8432
|
except sqlite3.Error as exc:
|
|
8430
|
-
cache_db_status = {"path": str(CACHE_DB_PATH), "user_version": 0,
|
|
8433
|
+
cache_db_status = {"path": str(_cctally_core.CACHE_DB_PATH), "user_version": 0,
|
|
8431
8434
|
"registry_size": len(_CACHE_MIGRATIONS),
|
|
8432
8435
|
"migrations": [], "_open_error": str(exc)}
|
|
8433
8436
|
|
|
@@ -8436,8 +8439,8 @@ def doctor_gather_state(
|
|
|
8436
8439
|
forked_bucket_counts: dict | None = None
|
|
8437
8440
|
credited_weeks: list[dict] | None = None
|
|
8438
8441
|
try:
|
|
8439
|
-
if DB_PATH.exists():
|
|
8440
|
-
conn = sqlite3.connect(str(DB_PATH))
|
|
8442
|
+
if _cctally_core.DB_PATH.exists():
|
|
8443
|
+
conn = sqlite3.connect(str(_cctally_core.DB_PATH))
|
|
8441
8444
|
try:
|
|
8442
8445
|
try:
|
|
8443
8446
|
row = conn.execute(
|
|
@@ -8534,8 +8537,8 @@ def doctor_gather_state(
|
|
|
8534
8537
|
cache_entries_count = None
|
|
8535
8538
|
cache_last_entry_at = None
|
|
8536
8539
|
try:
|
|
8537
|
-
if CACHE_DB_PATH.exists():
|
|
8538
|
-
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
8540
|
+
if _cctally_core.CACHE_DB_PATH.exists():
|
|
8541
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
8539
8542
|
try:
|
|
8540
8543
|
row = conn.execute(
|
|
8541
8544
|
"SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
|
|
@@ -8564,8 +8567,8 @@ def doctor_gather_state(
|
|
|
8564
8567
|
codex_entries_count = None
|
|
8565
8568
|
codex_last_entry_at = None
|
|
8566
8569
|
try:
|
|
8567
|
-
if CACHE_DB_PATH.exists():
|
|
8568
|
-
conn = sqlite3.connect(str(CACHE_DB_PATH))
|
|
8570
|
+
if _cctally_core.CACHE_DB_PATH.exists():
|
|
8571
|
+
conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
|
|
8569
8572
|
try:
|
|
8570
8573
|
row = conn.execute(
|
|
8571
8574
|
"SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
|
|
@@ -8607,8 +8610,8 @@ def doctor_gather_state(
|
|
|
8607
8610
|
# check surfaces the corruption separately.
|
|
8608
8611
|
dashboard_bind_stored = "loopback"
|
|
8609
8612
|
try:
|
|
8610
|
-
if CONFIG_PATH.exists():
|
|
8611
|
-
raw_cfg = json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8613
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
8614
|
+
raw_cfg = json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8612
8615
|
if isinstance(raw_cfg, dict):
|
|
8613
8616
|
dashboard_bind_stored = (
|
|
8614
8617
|
_config_known_value(raw_cfg, "dashboard.bind") or "loopback"
|
|
@@ -8622,8 +8625,8 @@ def doctor_gather_state(
|
|
|
8622
8625
|
# (codex H1).
|
|
8623
8626
|
config_json_error = None
|
|
8624
8627
|
try:
|
|
8625
|
-
if CONFIG_PATH.exists():
|
|
8626
|
-
json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8628
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
8629
|
+
json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
8627
8630
|
except json.JSONDecodeError as exc:
|
|
8628
8631
|
config_json_error = f"{type(exc).__name__}: {exc}"
|
|
8629
8632
|
except OSError as exc:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
|
|
5
5
|
"homepage": "https://github.com/omrikais/cctally",
|
|
6
6
|
"repository": {
|