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/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
- seen_hashes: set[str] | None = None,
100
+ *,
101
+ dedupe_map: "dict[str, UsageEntry]",
67
102
  ) -> list[UsageEntry]:
68
- """Parse assistant entries from a JSONL file within the given time range."""
69
- entries: list[UsageEntry] = []
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
- entries.append(UsageEntry(
176
+ entry = UsageEntry(
130
177
  timestamp=ts,
131
- model=model.strip(),
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
- return entries
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.strip(),
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
- Honors `CCTALLY_TEST_CHANGELOG_PATH` override (the documented test pattern
292
- at `bin/cctally:86`). Falls back to `"dev"` when CHANGELOG is unreadable
293
- or has no stamped release entry yet (pre-release dev builds).
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 `_lib_changelog._read_latest_changelog_version`
296
- (re-exported on `bin/cctally` under the historical
297
- `_release_read_latest_release_version` name) — intentionally
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
- `bin/cctally` import. If CHANGELOG header format changes, update both.
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
- from pathlib import Path
302
- p = os.environ.get("CCTALLY_TEST_CHANGELOG_PATH")
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
- APP_DIR = pathlib.Path.home() / ".local" / "share" / "cctally"
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 stay in bin/cctally (defined at L2001-2023 right after
652
- # APP_DIR) moved bodies read them via ``c.UPDATE_STATE_PATH`` etc. so
653
- # ``setitem(ns, "UPDATE_LOG_PATH", tmp)`` in tests propagates with zero
654
- # sibling-side patches needed (same as Phase D #17/#18 path-constant
655
- # precedent).
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
- CONFIG_PATH = APP_DIR / "config.json"
831
- CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
832
- LOG_DIR = APP_DIR / "logs"
833
- MIGRATION_ERROR_LOG_PATH = LOG_DIR / "migration-errors.log"
834
-
835
- # Seed `_cctally_db`'s namespace with path constants the migration
836
- # framework + error sentinel + cmd_db_* helpers reach via bare-name
837
- # lookup. We have to do this AFTER LOG_DIR / MIGRATION_ERROR_LOG_PATH
838
- # / DB_PATH / CACHE_DB_PATH are defined (i.e. here, not at the eager-
839
- # load site near the top). Bare-name lookup keeps the existing
840
- # `monkeypatch.setitem(helper.__globals__, "MIGRATION_ERROR_LOG_PATH",
841
- # tmp)` test pattern working (the helper's `__globals__` is now
842
- # `_cctally_db.__dict__`, so setitem there hits the bare-name lookup
843
- # path inside the helper). Tests that redirect HOME via the conftest
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
- # Path constants for the `cctally update` feature. Centralised here
872
- # (alongside other APP_DIR-derived paths) so the test fixture loader in
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
- CLAUDE_SETTINGS_PATH = pathlib.Path.home() / ".claude" / "settings.json"
7727
-
7711
+ def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
7712
+ """Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
7728
7713
 
7729
- def _load_claude_settings(path: pathlib.Path = CLAUDE_SETTINGS_PATH) -> dict:
7730
- """Read ~/.claude/settings.json. Empty/missing {}. Malformed → SetupError."""
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 = CLAUDE_SETTINGS_PATH) -> pathlib.Path | None:
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 = CLAUDE_SETTINGS_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.11.0",
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": {