cctally 1.22.2 → 1.22.3
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 +13 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +419 -8891
- package/package.json +14 -1
package/bin/_cctally_setup.py
CHANGED
|
@@ -10,15 +10,22 @@ legacy bespoke hooks), the prompt / decision helpers, the four
|
|
|
10
10
|
and the `cmd_setup` entry point.
|
|
11
11
|
|
|
12
12
|
The settings.json I/O primitives (`_load_claude_settings`,
|
|
13
|
-
`_write_claude_settings_atomic`, `_backup_claude_settings`),
|
|
14
|
-
`
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`
|
|
19
|
-
`
|
|
20
|
-
|
|
21
|
-
|
|
13
|
+
`_write_claude_settings_atomic`, `_backup_claude_settings`), `SetupError`,
|
|
14
|
+
`_is_cctally_hook_command`, and `cmd_repair_symlinks` now LIVE HERE
|
|
15
|
+
(#125 Batch E, C10 — relocated from `bin/cctally`). bin/cctally
|
|
16
|
+
eager-re-exports each so doctor's `except c.SetupError`, the parser's
|
|
17
|
+
`set_defaults(func=c.cmd_repair_symlinks)`, and the setup tests'
|
|
18
|
+
`monkeypatch.setitem(ns, "_load_claude_settings", …)` resolve unchanged.
|
|
19
|
+
The `_LEGACY_*` constants and `CLAUDE_SETTINGS_PATH` stay in
|
|
20
|
+
`bin/cctally` / `_cctally_core` — preserves the existing `_e2e_pin_paths`
|
|
21
|
+
test workaround verbatim (§5.4), plus keeps the monkeypatch-sensitive
|
|
22
|
+
`_LEGACY_BESPOKE_HOOKS_DIR` / `_LEGACY_POLLER_PID_FILE` /
|
|
23
|
+
`_LEGACY_POLLER_COUNT_FILE` constants on `cctally` where
|
|
24
|
+
`monkeypatch.setitem(ns, ...)` lands. Helpers in this module reach the
|
|
25
|
+
`_LEGACY_*` constants via `_cctally().<NAME>` (call-time lookup,
|
|
26
|
+
monkeypatch propagates); the C10 helpers above are same-module locals but
|
|
27
|
+
THIS module's existing call sites deliberately keep reaching them via
|
|
28
|
+
`c.<NAME>` so ns-level `setitem` patches stay visible (Codex round-1 P1).
|
|
22
29
|
|
|
23
30
|
bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same as
|
|
24
31
|
`bin/_lib_subscription_weeks.py` and `bin/_lib_aggregators.py`):
|
|
@@ -30,12 +37,14 @@ bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same as
|
|
|
30
37
|
`_LEGACY_BESPOKE_FILENAMES`, `_LEGACY_POLLER_PID_FILE`,
|
|
31
38
|
`_LEGACY_POLLER_COUNT_FILE`, `_LEGACY_BACKUP_DIR_PREFIX`,
|
|
32
39
|
`_LEGACY_POLLER_SIGTERM_GRACE_S`.
|
|
33
|
-
- Shared helpers: `eprint`, `
|
|
34
|
-
`_load_claude_settings`, `_write_claude_settings_atomic`,
|
|
35
|
-
`_backup_claude_settings`, `_resolve_oauth_token`,
|
|
40
|
+
- Shared helpers: `eprint`, `_resolve_oauth_token`,
|
|
36
41
|
`_hook_tick_throttle_age_seconds`, `_hook_tick_oauth_refresh`,
|
|
37
42
|
`_hook_tick_throttle_touch`, `_command_as_of`, `open_cache_db`,
|
|
38
|
-
`sync_cache`.
|
|
43
|
+
`sync_cache`. (`SetupError`, `_is_cctally_hook_command`,
|
|
44
|
+
`_load_claude_settings`, `_write_claude_settings_atomic`,
|
|
45
|
+
`_backup_claude_settings` are now LOCAL to this module — C10 — but the
|
|
46
|
+
call sites here still reach them via `c.<NAME>` for the monkeypatch
|
|
47
|
+
contract, so functionally they read like the back-references above.)
|
|
39
48
|
|
|
40
49
|
bin/cctally re-exports every public symbol below so tests that drive
|
|
41
50
|
`cmd_setup` and the legacy-migration helpers via `ns["X"](...)` resolve
|
|
@@ -110,6 +119,129 @@ _DEV_SETUP_FORCE_DEV_OVERRIDE_WARNING = (
|
|
|
110
119
|
)
|
|
111
120
|
|
|
112
121
|
|
|
122
|
+
# ── settings/hook glue (#125 Batch E, C10) ─────────────────────────────
|
|
123
|
+
# Moved here from bin/cctally. bin/cctally re-exports each via the
|
|
124
|
+
# _cctally_setup load site so doctor's `except c.SetupError`, the parser's
|
|
125
|
+
# `set_defaults(func=c.cmd_repair_symlinks)`, and the setup tests'
|
|
126
|
+
# `monkeypatch.setitem(ns, "_load_claude_settings", …)` all resolve to
|
|
127
|
+
# these objects. The sibling's OWN reaches to these helpers deliberately
|
|
128
|
+
# STAY `c.<name>` (NOT local) so ns-level monkeypatches propagate.
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_cctally_hook_command(cmd: str) -> bool:
|
|
132
|
+
"""Return True if `cmd` is one of OUR hook entries (Section 4 of spec).
|
|
133
|
+
|
|
134
|
+
Identification is shlex-aware so quoted absolute paths (with spaces)
|
|
135
|
+
and bare names both match. The discriminator is the LAST TWO tokens
|
|
136
|
+
after stripping any trailing `&` and surrounding whitespace:
|
|
137
|
+
|
|
138
|
+
tokens[-2] = a path whose basename is ``cctally`` or
|
|
139
|
+
the npm shim (``_CCTALLY_NPM_SHIM_BASENAME``; npm install
|
|
140
|
+
layout — the hook command points at the Node shim so
|
|
141
|
+
``CCTALLY_PYTHON`` propagates from the user's shell env
|
|
142
|
+
into hook fires)
|
|
143
|
+
tokens[-1] = "hook-tick"
|
|
144
|
+
|
|
145
|
+
Examples that match:
|
|
146
|
+
cctally hook-tick
|
|
147
|
+
cctally hook-tick &
|
|
148
|
+
/Users/me/.local/bin/cctally hook-tick &
|
|
149
|
+
'/Users/My Name/.local/bin/cctally' hook-tick &
|
|
150
|
+
/usr/local/lib/node_modules/cctally/bin/<npm-shim> hook-tick
|
|
151
|
+
"""
|
|
152
|
+
import shlex
|
|
153
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
154
|
+
return False
|
|
155
|
+
stripped = cmd.strip()
|
|
156
|
+
# Strip trailing &; allow whitespace before it.
|
|
157
|
+
while stripped.endswith("&"):
|
|
158
|
+
stripped = stripped[:-1].rstrip()
|
|
159
|
+
if not stripped:
|
|
160
|
+
return False
|
|
161
|
+
try:
|
|
162
|
+
tokens = shlex.split(stripped)
|
|
163
|
+
except ValueError:
|
|
164
|
+
return False
|
|
165
|
+
if len(tokens) < 2:
|
|
166
|
+
return False
|
|
167
|
+
last = tokens[-1]
|
|
168
|
+
prev = tokens[-2]
|
|
169
|
+
if last != "hook-tick":
|
|
170
|
+
return False
|
|
171
|
+
return pathlib.PurePosixPath(prev).name in (
|
|
172
|
+
"cctally", _CCTALLY_NPM_SHIM_BASENAME,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SetupError(RuntimeError):
|
|
177
|
+
"""Raised when setup hits a hard prerequisite failure (Section 2 of spec)."""
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _load_claude_settings(path: pathlib.Path | None = None) -> dict:
|
|
181
|
+
"""Read ~/.claude/settings.json. Empty/missing → {}. Malformed → SetupError.
|
|
182
|
+
|
|
183
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
184
|
+
TIME when omitted, so ``monkeypatch.setattr(_cctally_core,
|
|
185
|
+
"CLAUDE_SETTINGS_PATH", tmp)`` propagates without needing to swap
|
|
186
|
+
out this callable. Capturing the default at def-time would silently
|
|
187
|
+
pin the maintainer's real ``~/.claude/settings.json``.
|
|
188
|
+
"""
|
|
189
|
+
if path is None:
|
|
190
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
191
|
+
if not path.exists():
|
|
192
|
+
return {}
|
|
193
|
+
raw = path.read_text(encoding="utf-8")
|
|
194
|
+
if not raw.strip():
|
|
195
|
+
return {}
|
|
196
|
+
try:
|
|
197
|
+
data = json.loads(raw)
|
|
198
|
+
except json.JSONDecodeError as exc:
|
|
199
|
+
raise SetupError(
|
|
200
|
+
f"settings.json at {path} is not valid JSON: {exc}. Fix it and re-run."
|
|
201
|
+
) from exc
|
|
202
|
+
if not isinstance(data, dict):
|
|
203
|
+
raise SetupError(f"settings.json at {path} is not a JSON object — fix and re-run.")
|
|
204
|
+
return data
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _backup_claude_settings(path: pathlib.Path | None = None) -> pathlib.Path | None:
|
|
208
|
+
"""Best-effort daily backup; return backup path or None.
|
|
209
|
+
|
|
210
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
211
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
212
|
+
"""
|
|
213
|
+
if path is None:
|
|
214
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
215
|
+
if not path.exists():
|
|
216
|
+
return None
|
|
217
|
+
today = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%d")
|
|
218
|
+
backup = path.with_name(path.name + f".cctally-backup-{today}")
|
|
219
|
+
if backup.exists():
|
|
220
|
+
return backup
|
|
221
|
+
try:
|
|
222
|
+
shutil.copy2(path, backup)
|
|
223
|
+
except OSError as exc:
|
|
224
|
+
eprint(f"[setup] backup failed (continuing): {exc}")
|
|
225
|
+
return None
|
|
226
|
+
return backup
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _write_claude_settings_atomic(
|
|
230
|
+
settings: dict, path: pathlib.Path | None = None
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Atomic write with 2-space indent, trailing newline.
|
|
233
|
+
|
|
234
|
+
``path`` resolves to ``_cctally_core.CLAUDE_SETTINGS_PATH`` at CALL
|
|
235
|
+
TIME when omitted (see ``_load_claude_settings`` for rationale).
|
|
236
|
+
"""
|
|
237
|
+
if path is None:
|
|
238
|
+
path = _cctally_core.CLAUDE_SETTINGS_PATH
|
|
239
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
241
|
+
tmp.write_text(json.dumps(settings, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
242
|
+
os.replace(tmp, path)
|
|
243
|
+
|
|
244
|
+
|
|
113
245
|
# ── settings.json hook surgery ─────────────────────────────────────────
|
|
114
246
|
|
|
115
247
|
|
|
@@ -417,6 +549,33 @@ def _setup_repair_symlinks(
|
|
|
417
549
|
return _RepairResult(gated=False, created=created, failed=failed)
|
|
418
550
|
|
|
419
551
|
|
|
552
|
+
def cmd_repair_symlinks(args: argparse.Namespace) -> int:
|
|
553
|
+
"""Hidden: additively create missing ~/.local/bin/ symlinks on upgrade.
|
|
554
|
+
|
|
555
|
+
Invoked best-effort by the npm postinstall (issue #114). Refuses from
|
|
556
|
+
a dev checkout (would point ~/.local/bin at the dev tree). Touches
|
|
557
|
+
only symlinks — see _setup_repair_symlinks. Exempted from main()'s
|
|
558
|
+
post-command update hooks (see _post_command_update_hooks).
|
|
559
|
+
"""
|
|
560
|
+
if _cctally_core._is_dev_checkout():
|
|
561
|
+
eprint(
|
|
562
|
+
"repair-symlinks: refusing to run from a dev checkout "
|
|
563
|
+
"(would point ~/.local/bin at the dev tree)"
|
|
564
|
+
)
|
|
565
|
+
return 2
|
|
566
|
+
repo_root = _setup_resolve_repo_root()
|
|
567
|
+
dst_dir = _setup_local_bin_dir()
|
|
568
|
+
result = _setup_repair_symlinks(repo_root, dst_dir)
|
|
569
|
+
if result.created:
|
|
570
|
+
print(
|
|
571
|
+
f"cctally: linked {len(result.created)} new command symlink(s): "
|
|
572
|
+
+ ", ".join(result.created)
|
|
573
|
+
)
|
|
574
|
+
for name, detail in result.failed:
|
|
575
|
+
eprint(f"repair-symlinks: {name}: {detail}")
|
|
576
|
+
return 1 if result.failed else 0
|
|
577
|
+
|
|
578
|
+
|
|
420
579
|
def _setup_cleanup_stale_symlinks(
|
|
421
580
|
dst_dir: pathlib.Path,
|
|
422
581
|
) -> list[_SetupSymlinkResult]:
|