cctally 1.11.0 → 1.11.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.
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.11.1",
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": {