cctally 1.7.0 → 1.7.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/CHANGELOG.md +7 -0
- package/bin/_cctally_alerts.py +231 -0
- package/bin/_cctally_cache.py +1432 -0
- package/bin/_cctally_config.py +560 -0
- package/bin/_cctally_dashboard.py +5218 -0
- package/bin/_cctally_db.py +1729 -0
- package/bin/_cctally_record.py +2120 -0
- package/bin/_cctally_refresh.py +812 -0
- package/bin/_cctally_release.py +751 -0
- package/bin/_cctally_setup.py +1571 -0
- package/bin/_cctally_sync_week.py +110 -0
- package/bin/_cctally_tui.py +4381 -0
- package/bin/_cctally_update.py +2132 -0
- package/bin/_lib_aggregators.py +712 -0
- package/bin/_lib_alerts_payload.py +194 -0
- package/bin/_lib_blocks.py +414 -0
- package/bin/_lib_diff_kernel.py +1618 -0
- package/bin/_lib_display_tz.py +361 -0
- package/bin/_lib_doctor.py +58 -0
- package/bin/_lib_five_hour.py +82 -0
- package/bin/_lib_jsonl.py +403 -0
- package/bin/_lib_pricing.py +520 -0
- package/bin/_lib_render.py +2785 -0
- package/bin/_lib_semver.py +105 -0
- package/bin/_lib_subscription_weeks.py +492 -0
- package/bin/cctally +11034 -35415
- package/package.json +24 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.1] - 2026-05-15
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- `cctally record-usage` / `sync-week`: `week_start_date` bucket key is now anchored on the canonical UTC calendar day of `week_start_at`, not the host-local-TZ `.date()` of the parsed datetime. When the cctally process briefly inherits a non-UTC `TZ` (e.g., `TZ=America/Los_Angeles` for a `+03:00` host process during refactor work), the same physical subscription week silently forks across two `week_start_date` values, leaving `cctally report` Trend with two rows per current window — one frozen at the moment of the TZ flip, one still updating. The writer fix at `_derive_week_from_payload` / `pick_week_selection` prevents new ghosts; a companion self-heal migration `004_heal_forked_week_start_date_buckets` merges any pre-existing forked rows on the next `open_db()` (usage/cost UPDATE the date columns to `substr(at, 1, 10)`; milestones DELETE on `UNIQUE(week_start_date, percent_threshold)` collision against the canonical row, else UPDATE). A new `data.forked_buckets` doctor check (visible in `cctally doctor` and the dashboard) surfaces the invariant as `fail` with per-table counts so the next regression is visible immediately. `_bootstrap_rename_legacy_markers` is now idempotent against the duplicate-marker case — both the legacy unprefixed and the new prefixed marker rows present from a back-and-forth across cctally versions — by DELETEing the legacy row when its prefixed counterpart already exists and preserving the prefixed row's authoritative `applied_at_utc`; previously the plain UPDATE collided on `schema_migrations.PRIMARY KEY` and permanently blocked the dispatcher from running any subsequent migration (including the heal).
|
|
12
|
+
- `brew` installs: `cctally --help`, `cctally doctor`, the dashboard share GUI, and the CLI `--format md|html|svg` flag no longer crash with `FileNotFoundError` looking for runtime sibling modules. The Homebrew formula template's install block enumerated only `USER_FACING_BINS` since v1.4.0, so the lazy-loaded `_lib_doctor.py` / `_lib_share.py` / `_lib_share_templates.py` siblings never reached `libexec/bin` on brew layouts — `doctor`, the share modal, and the `--format` flag have been latently broken on every brew install since they landed. The v1.6.1 CHANGELOG note that "Homebrew copies the whole prefix; brew unaffected" was incorrect — the formula has always copied a per-name list, not the whole prefix. The bin/cctally split refactor on this branch promoted `_lib_semver` to an EAGER import at `bin/cctally:213`, which would have turned the latent crash into an immediate one (`cctally --help` itself stops resolving on a brew install missing the sibling). `homebrew/cctally.rb.template` now installs every `bin/_lib_*.py` and `bin/_cctally_*.py` runtime sibling via `Dir.glob` alongside `USER_FACING_BINS`, and `tests/test_package_files.py` gains a parity guard so future sibling additions can't silently drop out of the brew install layout. The next release cut after this branch merges ships a working brew formula for the first time since v1.4.0.
|
|
13
|
+
- `update` (self-heal): `_self_heal_current_version` no longer corrupts the global `update-state.json` when `cctally` is invoked from a development clone. The post-command hook reads `CHANGELOG_PATH` via `__file__` (resolved against the dev tree's `CHANGELOG.md`, not the installed binary's), so any `./bin/cctally` invocation from the source tree — including the six phases of `cctally release` itself — stamped `current_version` to whatever the dev tree's CHANGELOG claimed, masking the actually-installed version on the user's machine until the next `rm ~/.local/share/cctally/update-state.json`. The self-heal now early-returns when a `.git/` directory sits next to `CHANGELOG_PATH`, since production tarballs (npm tar, brew archive) never ship `.git/`; legitimate out-of-band upgrades on installed npm/brew binaries still self-heal as before. Symmetric twin of the v1.7.0 brew fix (CHANGELOG-via-`__file__` ≠ installed-binary's CHANGELOG); same root cause, different trigger. Resolves [#42](https://github.com/omrikais/cctally-dev/issues/42).
|
|
14
|
+
|
|
8
15
|
## [1.7.0] - 2026-05-13
|
|
9
16
|
|
|
10
17
|
### Added
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Alert dispatch I/O + `cctally alerts test` entry point.
|
|
2
|
+
|
|
3
|
+
Lazy I/O sibling: holds the two helpers that perform real-world side
|
|
4
|
+
effects for the threshold-actions feature, plus the test-entry command:
|
|
5
|
+
|
|
6
|
+
- `_alerts_log_path()` — resolve / mkdir the `alerts.log` path under
|
|
7
|
+
`LOG_DIR`. Pure path-derivation that touches the filesystem (creates
|
|
8
|
+
the parent dir) on every call.
|
|
9
|
+
- `_dispatch_alert_notification(payload, *, popen_factory, mode, tz)` —
|
|
10
|
+
spawn `osascript` (best-effort, non-blocking) to display a macOS
|
|
11
|
+
Notification Center popup, then append a single tab-delimited line to
|
|
12
|
+
`alerts.log` with the terminal status. Fire-and-forget contract; never
|
|
13
|
+
raises.
|
|
14
|
+
- `cmd_alerts_test(args)` — synthetic-payload entry point exposed via
|
|
15
|
+
`cctally alerts test`. Builds a payload through the same
|
|
16
|
+
`_build_alert_payload_*` helpers production uses, routes through
|
|
17
|
+
`_dispatch_alert_notification` with `mode="test"`, and reports the
|
|
18
|
+
outcome via stdout / exit code.
|
|
19
|
+
|
|
20
|
+
The pure payload primitives (`_alert_text_weekly`,
|
|
21
|
+
`_alert_text_five_hour`, `_escape_applescript_string`,
|
|
22
|
+
`_build_alert_payload_weekly`, `_build_alert_payload_five_hour`) live
|
|
23
|
+
in `bin/_lib_alerts_payload.py` (Phase A extraction); this module
|
|
24
|
+
imports them directly via `_load_lib`, which keeps the dispatch path
|
|
25
|
+
free of an extra bounce through cctally's re-exports.
|
|
26
|
+
|
|
27
|
+
bin/cctally back-references via `_cctally()` (spec §5.5 pattern, same
|
|
28
|
+
as `bin/_cctally_setup.py`):
|
|
29
|
+
- `LOG_DIR` — base dir under which `alerts.log` lives (subject to
|
|
30
|
+
HOME-redirection by test fixtures via `monkeypatch.setitem(ns,
|
|
31
|
+
"LOG_DIR", ...)`).
|
|
32
|
+
- `now_utc_iso` — single timestamp source used for both the log-line
|
|
33
|
+
timestamp and the synthetic test payload's `crossed_at_utc`.
|
|
34
|
+
|
|
35
|
+
bin/cctally re-exports every public symbol below so the
|
|
36
|
+
`bin/cctally-alerts-dispatch-test` harness (SourceFileLoader-based,
|
|
37
|
+
attribute access via `m._dispatch_alert_notification(...)`) and the
|
|
38
|
+
existing internal call sites in `cmd_record_usage` + the dashboard
|
|
39
|
+
alerts/test handler resolve unchanged.
|
|
40
|
+
|
|
41
|
+
Spec: docs/superpowers/specs/2026-05-13-bin-cctally-split-design.md
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import datetime as dt
|
|
47
|
+
import importlib.util as _ilu
|
|
48
|
+
import os
|
|
49
|
+
import pathlib
|
|
50
|
+
import subprocess
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cctally():
|
|
55
|
+
"""Resolve the current `cctally` module at call-time (spec §5.5)."""
|
|
56
|
+
return sys.modules["cctally"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_lib(name: str):
|
|
60
|
+
cached = sys.modules.get(name)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
return cached
|
|
63
|
+
p = pathlib.Path(__file__).resolve().parent / f"{name}.py"
|
|
64
|
+
spec = _ilu.spec_from_file_location(name, p)
|
|
65
|
+
mod = _ilu.module_from_spec(spec)
|
|
66
|
+
sys.modules[name] = mod
|
|
67
|
+
spec.loader.exec_module(mod)
|
|
68
|
+
return mod
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
_lib_alerts_payload = _load_lib("_lib_alerts_payload")
|
|
72
|
+
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
73
|
+
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
74
|
+
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
75
|
+
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
76
|
+
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _alerts_log_path() -> "pathlib.Path":
|
|
80
|
+
"""Return ``~/.local/share/cctally/logs/alerts.log`` (parent dirs created).
|
|
81
|
+
|
|
82
|
+
Resolves through the same ``APP_DIR`` / ``LOG_DIR`` derived at module
|
|
83
|
+
import time from ``Path.home()``, so a HOME override before import (the
|
|
84
|
+
pattern used elsewhere in this codebase — e.g. ``cctally-config-test``)
|
|
85
|
+
transparently relocates the log without a separate env-var convention.
|
|
86
|
+
"""
|
|
87
|
+
log_dir = _cctally().LOG_DIR
|
|
88
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
return log_dir / "alerts.log"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _dispatch_alert_notification(
|
|
93
|
+
payload: dict,
|
|
94
|
+
*,
|
|
95
|
+
popen_factory=subprocess.Popen,
|
|
96
|
+
mode: str = "real",
|
|
97
|
+
tz: "object | None" = None,
|
|
98
|
+
) -> str:
|
|
99
|
+
"""Spawn osascript to display a macOS notification (non-blocking, best-effort).
|
|
100
|
+
|
|
101
|
+
Returns ``"queued"`` on successful Popen, ``"spawn_error: <ExcType>: <msg>"``
|
|
102
|
+
on failure. Writes EXACTLY ONE line to ``alerts.log`` with the terminal
|
|
103
|
+
status (no contradictory pre-/post-Popen log pair). Never raises:
|
|
104
|
+
Popen-spawn failures and log-write failures are both swallowed so the
|
|
105
|
+
dispatch contract stays independent of the OS / FS state.
|
|
106
|
+
|
|
107
|
+
Production callers ignore the return value (fire-and-forget); test
|
|
108
|
+
callers assert on it via an injected ``popen_factory``.
|
|
109
|
+
|
|
110
|
+
Integration-harness escape hatch: when ``popen_factory`` is left as
|
|
111
|
+
its default (``subprocess.Popen``) AND the env var
|
|
112
|
+
``CCTALLY_TEST_POPEN_FACTORY=raise_filenotfound`` is set, swap in a
|
|
113
|
+
factory that raises ``FileNotFoundError("no osascript")``. Used by
|
|
114
|
+
``bin/cctally-alerts-test`` to exercise the spawn-error branch
|
|
115
|
+
end-to-end (subprocess invocation of ``cctally record-usage`` —
|
|
116
|
+
direct kwargs-injection isn't reachable through the CLI). Only the
|
|
117
|
+
one canonical token is honored; unknown values fall through to real
|
|
118
|
+
Popen so a typo can't silently neuter dispatch in production.
|
|
119
|
+
"""
|
|
120
|
+
if (
|
|
121
|
+
popen_factory is subprocess.Popen
|
|
122
|
+
and os.environ.get("CCTALLY_TEST_POPEN_FACTORY") == "raise_filenotfound"
|
|
123
|
+
):
|
|
124
|
+
def _raise_filenotfound(*_args, **_kwargs):
|
|
125
|
+
raise FileNotFoundError("no osascript")
|
|
126
|
+
popen_factory = _raise_filenotfound
|
|
127
|
+
|
|
128
|
+
axis = payload["axis"]
|
|
129
|
+
if axis == "weekly":
|
|
130
|
+
title, subtitle, body = _alert_text_weekly(payload, tz)
|
|
131
|
+
elif axis == "five_hour":
|
|
132
|
+
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
133
|
+
else:
|
|
134
|
+
title, subtitle, body = (
|
|
135
|
+
"cctally - alert",
|
|
136
|
+
"",
|
|
137
|
+
f"axis={axis} threshold={payload.get('threshold')}",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
script = (
|
|
141
|
+
f'display notification "{_escape_applescript_string(body)}"'
|
|
142
|
+
f' with title "{_escape_applescript_string(title)}"'
|
|
143
|
+
f' subtitle "{_escape_applescript_string(subtitle)}"'
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
status: str
|
|
147
|
+
try:
|
|
148
|
+
popen_factory(
|
|
149
|
+
["osascript", "-e", script],
|
|
150
|
+
stdout=subprocess.DEVNULL,
|
|
151
|
+
stderr=subprocess.DEVNULL,
|
|
152
|
+
close_fds=True,
|
|
153
|
+
start_new_session=True,
|
|
154
|
+
)
|
|
155
|
+
status = "queued"
|
|
156
|
+
except (FileNotFoundError, PermissionError, OSError) as exc:
|
|
157
|
+
status = f"spawn_error: {exc.__class__.__name__}: {exc}"
|
|
158
|
+
|
|
159
|
+
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no
|
|
160
|
+
# contradictory "queued" + "spawn_error" pair for the same call).
|
|
161
|
+
try:
|
|
162
|
+
log_path = _alerts_log_path()
|
|
163
|
+
ctx = payload.get("context") or {}
|
|
164
|
+
window_key = (
|
|
165
|
+
ctx.get("week_start_date")
|
|
166
|
+
or ctx.get("five_hour_window_key")
|
|
167
|
+
or ""
|
|
168
|
+
)
|
|
169
|
+
line = (
|
|
170
|
+
f"{_cctally().now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
|
|
171
|
+
f"\t{mode}\t{status}\n"
|
|
172
|
+
)
|
|
173
|
+
with open(log_path, "a") as f:
|
|
174
|
+
f.write(line)
|
|
175
|
+
except OSError:
|
|
176
|
+
pass # log-write failures must not affect dispatch contract
|
|
177
|
+
|
|
178
|
+
return status
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
182
|
+
"""Send a synthetic test alert through the dispatch pipeline.
|
|
183
|
+
|
|
184
|
+
Builds a synthetic payload via the same ``_build_alert_payload_*``
|
|
185
|
+
helpers production uses, then routes through ``_dispatch_alert_notification``
|
|
186
|
+
with ``mode="test"`` so the alerts.log line carries the ``test``
|
|
187
|
+
discriminator (5th tab-delimited field) — distinguishes from real
|
|
188
|
+
threshold-crossing alerts written by ``cmd_record_usage``.
|
|
189
|
+
|
|
190
|
+
No DB writes: this path exists purely to validate end-to-end
|
|
191
|
+
osascript + log behavior. Exit codes:
|
|
192
|
+
0 alert was queued (Popen succeeded)
|
|
193
|
+
1 osascript missing on this host (FileNotFoundError)
|
|
194
|
+
2 --threshold out of [1, 100] range
|
|
195
|
+
3 other spawn error (PermissionError, OSError, ...)
|
|
196
|
+
"""
|
|
197
|
+
c = _cctally()
|
|
198
|
+
axis = "weekly" if args.axis == "weekly" else "five_hour"
|
|
199
|
+
threshold = int(args.threshold)
|
|
200
|
+
if not (1 <= threshold <= 100):
|
|
201
|
+
print(
|
|
202
|
+
f"cctally: --threshold must be in [1, 100], got {threshold}",
|
|
203
|
+
file=sys.stderr,
|
|
204
|
+
)
|
|
205
|
+
return 2
|
|
206
|
+
if axis == "weekly":
|
|
207
|
+
payload = _build_alert_payload_weekly(
|
|
208
|
+
threshold=threshold,
|
|
209
|
+
crossed_at_utc=c.now_utc_iso(),
|
|
210
|
+
week_start_date=dt.date.today().isoformat(),
|
|
211
|
+
cumulative_cost_usd=1.23,
|
|
212
|
+
dollars_per_percent=0.01,
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
payload = _build_alert_payload_five_hour(
|
|
216
|
+
threshold=threshold,
|
|
217
|
+
crossed_at_utc=c.now_utc_iso(),
|
|
218
|
+
five_hour_window_key=int(dt.datetime.now(dt.timezone.utc).timestamp()),
|
|
219
|
+
block_start_at=c.now_utc_iso(),
|
|
220
|
+
block_cost_usd=1.23,
|
|
221
|
+
primary_model="claude-sonnet-4-6",
|
|
222
|
+
)
|
|
223
|
+
status = _dispatch_alert_notification(payload, mode="test")
|
|
224
|
+
if status == "queued":
|
|
225
|
+
print("Test alert dispatched (mode=test). Check Notification Center.")
|
|
226
|
+
return 0
|
|
227
|
+
if "FileNotFoundError" in status:
|
|
228
|
+
print(f"cctally: {status}", file=sys.stderr)
|
|
229
|
+
return 1
|
|
230
|
+
print(f"cctally: {status}", file=sys.stderr)
|
|
231
|
+
return 3
|