cctally 1.11.1 → 1.13.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 +62 -0
- package/bin/_cctally_cache.py +342 -113
- package/bin/_cctally_config.py +55 -9
- package/bin/_cctally_core.py +51 -0
- package/bin/_cctally_db.py +1654 -5
- package/bin/_cctally_record.py +1 -1
- package/bin/_cctally_setup.py +11 -1
- package/bin/_lib_diff_kernel.py +14 -4
- package/bin/_lib_jsonl.py +88 -17
- package/bin/_lib_render.py +193 -22
- package/bin/_lib_subscription_weeks.py +21 -3
- package/bin/cctally +1278 -85
- package/package.json +1 -1
package/bin/_cctally_config.py
CHANGED
|
@@ -46,6 +46,7 @@ import json
|
|
|
46
46
|
import os
|
|
47
47
|
import secrets
|
|
48
48
|
import sys
|
|
49
|
+
from pathlib import Path
|
|
49
50
|
from typing import Any
|
|
50
51
|
|
|
51
52
|
|
|
@@ -162,17 +163,60 @@ def config_writer_lock():
|
|
|
162
163
|
fh.close()
|
|
163
164
|
|
|
164
165
|
|
|
165
|
-
def
|
|
166
|
+
def _load_config_from_explicit_path(path: "str | Path") -> dict[str, Any]:
|
|
167
|
+
"""Read config from an explicit per-invocation override path (issue #88).
|
|
168
|
+
|
|
169
|
+
Contract differs from the default ``load_config()``:
|
|
170
|
+
- Missing file → ``SystemExit(2)`` with a clear stderr message.
|
|
171
|
+
- Unreadable / malformed JSON / non-object root → ``SystemExit(2)``
|
|
172
|
+
with a clear stderr message.
|
|
173
|
+
- Never writes, never acquires ``config_writer_lock``, never
|
|
174
|
+
creates the on-disk default config — the override is read-only
|
|
175
|
+
for this invocation.
|
|
176
|
+
|
|
177
|
+
Used by the ccusage drop-in ``--config <path>`` flag wired onto the
|
|
178
|
+
10 Claude reporting commands (spec §3 T1.6 / issue #86 Session A).
|
|
179
|
+
"""
|
|
180
|
+
p = Path(path)
|
|
181
|
+
if not p.exists():
|
|
182
|
+
eprint(f"cctally: --config: file not found: {p}")
|
|
183
|
+
raise SystemExit(2)
|
|
184
|
+
try:
|
|
185
|
+
raw = p.read_text(encoding="utf-8")
|
|
186
|
+
except OSError as exc:
|
|
187
|
+
eprint(f"cctally: --config: read failed for {p}: {exc}")
|
|
188
|
+
raise SystemExit(2) from exc
|
|
189
|
+
try:
|
|
190
|
+
data = json.loads(raw)
|
|
191
|
+
except json.JSONDecodeError as exc:
|
|
192
|
+
eprint(f"cctally: --config: invalid JSON in {p}: {exc}")
|
|
193
|
+
raise SystemExit(2) from exc
|
|
194
|
+
if not isinstance(data, dict):
|
|
195
|
+
eprint(
|
|
196
|
+
f"cctally: --config: {p} top-level must be a JSON object"
|
|
197
|
+
)
|
|
198
|
+
raise SystemExit(2)
|
|
199
|
+
return data
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def load_config(path: "str | Path | None" = None) -> dict[str, Any]:
|
|
166
203
|
"""Read config.json, falling back to in-memory defaults on corruption.
|
|
167
204
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
205
|
+
When ``path`` is None (default): reads the persisted user config at
|
|
206
|
+
``_cctally_core.CONFIG_PATH``, creating it on first run with a fresh
|
|
207
|
+
collector token under the writer lock. Concurrent-safety: readers see
|
|
208
|
+
either the pre-rename or post-rename contents thanks to save_config's
|
|
209
|
+
atomic os.replace. On corrupt or non-object JSON, emits a one-shot
|
|
210
|
+
stderr warning and returns in-memory defaults WITHOUT re-saving — the
|
|
211
|
+
next legitimate save_config call (under config_writer_lock) will
|
|
212
|
+
overwrite the bad bytes atomically.
|
|
213
|
+
|
|
214
|
+
When ``path`` is set (issue #88 ccusage drop-in ``--config <path>``):
|
|
215
|
+
reads from the explicit override path and bypasses the default-path
|
|
216
|
+
branch entirely. Missing / unreadable / malformed paths surface as
|
|
217
|
+
``SystemExit(2)`` with a clear stderr message — see
|
|
218
|
+
``_load_config_from_explicit_path``. No writes, no first-run create,
|
|
219
|
+
no mutation of the on-disk default config.
|
|
176
220
|
|
|
177
221
|
DEADLOCK NOTE: `fcntl.flock` is per-fd even within the same
|
|
178
222
|
process. Callers that already hold config_writer_lock MUST use
|
|
@@ -180,6 +224,8 @@ def load_config() -> dict[str, Any]:
|
|
|
180
224
|
inside an outer lock would block forever (verified during issue
|
|
181
225
|
#17 fix).
|
|
182
226
|
"""
|
|
227
|
+
if path is not None:
|
|
228
|
+
return _load_config_from_explicit_path(path)
|
|
183
229
|
c = _cctally()
|
|
184
230
|
ensure_dirs()
|
|
185
231
|
parsed = _try_read_config()
|
package/bin/_cctally_core.py
CHANGED
|
@@ -67,6 +67,7 @@ def _init_paths_from_env() -> None:
|
|
|
67
67
|
global UPDATE_STATE_PATH, UPDATE_SUPPRESS_PATH
|
|
68
68
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
69
69
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
70
|
+
global CLAUDE_PROJECTS_DIR
|
|
70
71
|
|
|
71
72
|
home = pathlib.Path.home()
|
|
72
73
|
APP_DIR = home / ".local" / "share" / "cctally"
|
|
@@ -108,10 +109,60 @@ def _init_paths_from_env() -> None:
|
|
|
108
109
|
|
|
109
110
|
CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
|
|
110
111
|
|
|
112
|
+
# Claude session JSONL root. Production path is `~/.claude/projects`;
|
|
113
|
+
# exposed as a module-level constant so cross-DB migrations (e.g.
|
|
114
|
+
# stats migration 008) and the dispatcher's empty-disk fallback can
|
|
115
|
+
# honor a fixture override via tests' `monkeypatch.setattr(
|
|
116
|
+
# _cctally_core, "CLAUDE_PROJECTS_DIR", tmp_path / "...")`. The
|
|
117
|
+
# `_get_claude_data_dirs()` helper in bin/cctally remains the
|
|
118
|
+
# authoritative resolver for ad-hoc reads (multi-root + env-aware);
|
|
119
|
+
# this constant is the single-rooted production default that 99% of
|
|
120
|
+
# callers want. For multi-root, env-aware resolution (mirroring
|
|
121
|
+
# `_get_claude_data_dirs`), use `_resolve_claude_projects_dirs()`.
|
|
122
|
+
CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
|
|
123
|
+
|
|
111
124
|
|
|
112
125
|
_init_paths_from_env()
|
|
113
126
|
|
|
114
127
|
|
|
128
|
+
def _resolve_claude_projects_dirs() -> list[pathlib.Path]:
|
|
129
|
+
"""Return Claude Code projects dirs that exist on disk, env-aware.
|
|
130
|
+
|
|
131
|
+
Mirrors `_get_claude_data_dirs()` in bin/cctally but returns the
|
|
132
|
+
`projects/` subdir directly (since cross-DB migrations only care
|
|
133
|
+
about the JSONL root, not the parent Claude data dir). Honors
|
|
134
|
+
``CLAUDE_CONFIG_DIR`` (comma-separated multi-root) and falls back
|
|
135
|
+
to ``~/.config/claude`` then ``~/.claude``.
|
|
136
|
+
|
|
137
|
+
Used by stats migration 008's gate helper to avoid falsely
|
|
138
|
+
short-circuiting Layer C's empty-disk fallback when the user has
|
|
139
|
+
``CLAUDE_CONFIG_DIR=/other/path`` set AND no ``~/.claude/projects``
|
|
140
|
+
dir on disk: the gate would otherwise see zero JSONL files at the
|
|
141
|
+
hardcoded ``CLAUDE_PROJECTS_DIR`` and "pass" the gate, then run the
|
|
142
|
+
recompute as a no-op against an empty cache.
|
|
143
|
+
|
|
144
|
+
Tests can also feed an explicit list to the gate helper directly,
|
|
145
|
+
skipping this resolver.
|
|
146
|
+
"""
|
|
147
|
+
env_val = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
|
|
148
|
+
if env_val:
|
|
149
|
+
candidates = [pathlib.Path(p.strip()) for p in env_val.split(",") if p.strip()]
|
|
150
|
+
result = [
|
|
151
|
+
d / "projects"
|
|
152
|
+
for d in candidates
|
|
153
|
+
if d.is_dir() and (d / "projects").is_dir()
|
|
154
|
+
]
|
|
155
|
+
if result:
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
home = pathlib.Path.home()
|
|
159
|
+
defaults = [
|
|
160
|
+
home / ".config" / "claude",
|
|
161
|
+
home / ".claude",
|
|
162
|
+
]
|
|
163
|
+
return [d / "projects" for d in defaults if d.is_dir() and (d / "projects").is_dir()]
|
|
164
|
+
|
|
165
|
+
|
|
115
166
|
# === Logging =========================================================
|
|
116
167
|
|
|
117
168
|
|