cctally 1.22.4 → 1.24.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 +20 -0
- package/bin/_cctally_alerts.py +133 -24
- package/bin/_cctally_config.py +195 -14
- package/bin/_cctally_core.py +102 -2
- package/bin/_cctally_dashboard.py +277 -62
- package/bin/_cctally_forecast.py +25 -3
- package/bin/_cctally_milestones.py +68 -0
- package/bin/_cctally_parser.py +10 -2
- package/bin/_cctally_record.py +470 -137
- package/bin/_cctally_tui.py +1 -0
- package/bin/_lib_alert_axes.py +53 -0
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +67 -0
- package/bin/_lib_budget.py +8 -0
- package/bin/cctally +17 -0
- package/dashboard/static/assets/{index-BxmaYT1y.css → index-CsqqtRBB.css} +1 -1
- package/dashboard/static/assets/index-DwuW39Tv.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-CLcd-Tnm.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.24.0] - 2026-06-02
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Threshold alerts now dispatch cross-platform, not just on macOS.** Alongside the existing macOS `osascript` Notification Center popup, alerts can now fire via Linux `notify-send` (with the severity mapped to a `-u low|normal|critical` urgency token and a `--` end-of-options guard against option-injection) or via a fully custom command you configure — so Windows / WSL / headless setups can finally wire up a real popup. The backend is picked automatically per host (`auto`: a custom command if you've set one, else `osascript` on macOS, else `notify-send` on Linux, else no popup), and `cctally alerts test` now prints a `notifier: <resolved>` line so you can see exactly which backend will fire before relying on a real crossing. The dispatch decision lives in a new pure, fully-unit-tested kernel (`bin/_lib_alert_dispatch.py`) and every spawn is `shell=False` with the arg-list form, so alert text containing `$(...)`, `;`, or `&&` is passed as one literal argument and can never inject a shell command.
|
|
12
|
+
- **A new `alerts.command_template` config key lets you run any command on an alert.** Set it to a JSON argv list — e.g. `cctally config set alerts.command_template '["notify-send","-u","{urgency}","{title}","{body}"]'` — and cctally spawns that command (shell-free) on every crossing, substituting the documented `{title}`/`{subtitle}`/`{body}`/`{severity}`/`{urgency}`/`{axis}`/`{threshold}`/`{metric}` tokens (unmatched braces stay literal; a missing value substitutes as empty). This is trusted local execution (you own your config), and under the default `auto` notifier a set template takes over dispatch on every OS, so you can route alerts to a webhook, a logger, a phone push, or any tool you like. The companion `alerts.notifier` key (`auto` / `osascript` / `notify-send` / `command` / `none`) pins the backend explicitly when you don't want auto-detection, and both keys are editable from the dashboard Settings overlay (notifier dropdown) as well as `cctally config set`.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **Alert severity is now a 3-tier model (info / warn / critical) instead of the previous two-color split.** A crossed threshold below 90% is `info` (indigo), 90–99% is `warn` (amber), and 100%+ is `critical` (red); this single mapping drives the dashboard toast color, the "Recent alerts" panel chip, and the Linux `notify-send` urgency token, and it is kept byte-identical between the Python authority (`bin/_lib_alert_axes.py::severity_for`) and the dashboard (`alertSeverity` in `dashboard/web/src/lib/alertAxis.ts`), with legacy `amber`/`red` tokens from an older backend normalizing to `warn`/`critical` on read. The `alerts.log` audit file gains a 7th tab-delimited column carrying this severity on every dispatch line. No action needed on upgrade; existing alert config and thresholds are unchanged.
|
|
16
|
+
|
|
17
|
+
## [1.23.0] - 2026-06-02
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- **Projected-pace alerts: a new opt-in `projected` alert axis that warns you *before* you cross a ceiling, firing on your week-average pace rather than waiting for the actual crossing.** It tracks two metrics — `weekly_pct` (projected to reach 90% / 100% of your subscription cap by the week's reset) and `budget_usd` (projected to reach your `budget.alert_thresholds` of the weekly $ budget) — using the smooth week-average projection (`now + average-rate × time-remaining`), the same conservative number `forecast`/`budget` already display; it deliberately ignores the hotter trailing-24h estimate so a brief spike doesn't trigger a false alarm. Each level fires once per week (no re-fire, no recovery alert), is suppressed while the forecast is `LOW CONF` (too early in the week / too few samples), and re-anchors cleanly across a mid-week reset.
|
|
21
|
+
- **Both projected toggles default OFF, gated behind their parent axis** — enable weekly-% projected alerts with `cctally config set alerts.projected_enabled true` (requires `alerts.enabled`) and budget-$ projected alerts with `cctally config set budget.projected_enabled true` (requires a configured `budget.weekly_usd` + `budget.alerts_enabled`); preview either without writing any data via `cctally alerts test --axis projected --metric weekly_pct` (or `--metric budget_usd`). The local web dashboard surfaces fired projected alerts with a dedicated **Projected** chip and forecast-aware context, and exposes both enable/disable toggles in its settings panel.
|
|
22
|
+
- **`forecast --json` and `budget --json` now expose an additive `week_avg_projection_pct` / `week_avg_projection_usd` field** — the exact week-average end-of-week projection the projected-pace alert axis fires on, surfaced for scripting and reconciliation (additive only; no schema-version bump, existing keys unchanged).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
- **The mid-week reset-to-zero detector now waits for a corroborating second reading before segmenting the week (#128).** When the Anthropic usage API reports a clean drop to ~0% against non-trivial prior usage (below the 25pp goodwill-credit threshold), `record-usage` no longer writes a `week_reset_events` row on the very first zero: it arms a one-tick debounce and fires only if the next reading stays low (at or below half the pre-zero level), treating a zero that bounces back toward the prior percentage as a transient API/replica glitch rather than a real reset. A genuine reset still surfaces — one status-line tick later — and the ≥25pp credit path, the boundary-advance path, and the 5-hour detector are unchanged. This is belt-and-suspenders hardening on the 2026-06-01 surprise-reset fix; the debounce is best-effort under concurrent `record-usage` runs (a race degrades to the previous single-zero behavior, never worse).
|
|
26
|
+
- **Test-suite isolation (no shipped-code change): the `*_ns_patch.py` binding-regression tests no longer read the developer's real `~/.local/share/cctally` database (#127).** Their shared `cctally_mod` fixture loaded `bin/cctally` with a bespoke loader that only set `HOME` and relied on `_cctally_core`'s import-time path derivation — which silently no-ops once any earlier test has cached `_cctally_core` in `sys.modules` (every `load_script()` user does), so the handler under test fell back to the real prod DB. That stayed invisible until the prod DB happened to hold a `week_reset_events` row for the current week, at which point `test_percent_breakdown_md_reaches_all_accessors_via_ns` flipped to a failure that only reproduced under certain test orderings (it filters milestones by the DB's active segment, so the seeded fake milestone was dropped and the table never rendered). The five `*_ns_patch.py` fixtures now route through a shared `conftest.load_isolated_cctally_module()` helper built on `load_script()` + `redirect_paths()`, pinning `_cctally_core`'s path constants to the per-test tmp dir deterministically regardless of import order. A new order-independent regression (`test_ns_patch_loader_isolates_db_when_core_cached`) asserts the loader re-isolates `DB_PATH` even when `_cctally_core` is pre-cached pointing at a real prod path.
|
|
27
|
+
|
|
8
28
|
## [1.22.4] - 2026-06-01
|
|
9
29
|
|
|
10
30
|
### Fixed
|
package/bin/_cctally_alerts.py
CHANGED
|
@@ -47,6 +47,7 @@ import datetime as dt
|
|
|
47
47
|
import importlib.util as _ilu
|
|
48
48
|
import os
|
|
49
49
|
import pathlib
|
|
50
|
+
import shutil
|
|
50
51
|
import subprocess
|
|
51
52
|
import sys
|
|
52
53
|
|
|
@@ -69,10 +70,30 @@ _lib_alerts_payload = _load_lib("_lib_alerts_payload")
|
|
|
69
70
|
_alert_text_weekly = _lib_alerts_payload._alert_text_weekly
|
|
70
71
|
_alert_text_five_hour = _lib_alerts_payload._alert_text_five_hour
|
|
71
72
|
_alert_text_budget = _lib_alerts_payload._alert_text_budget
|
|
73
|
+
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
72
74
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
73
75
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
74
76
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
75
77
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
78
|
+
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
79
|
+
|
|
80
|
+
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
81
|
+
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
82
|
+
# that injects the real sys.platform / shutil.which and spawns with shell=False.
|
|
83
|
+
_lib_alert_axes = _load_lib("_lib_alert_axes")
|
|
84
|
+
severity_for = _lib_alert_axes.severity_for
|
|
85
|
+
_lib_alert_dispatch = _load_lib("_lib_alert_dispatch")
|
|
86
|
+
resolve_notifier = _lib_alert_dispatch.resolve_notifier
|
|
87
|
+
build_command = _lib_alert_dispatch.build_command
|
|
88
|
+
severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# `load_config` STAYS a shim that bounces through cctally's namespace (mirrors
|
|
92
|
+
# bin/_cctally_record.py): production monkeypatches `cctally.load_config`, and
|
|
93
|
+
# the dispatch tests patch this module-level name directly. Its natural home is
|
|
94
|
+
# _cctally_config; a direct import would silently bypass those patches.
|
|
95
|
+
def load_config(*args, **kwargs):
|
|
96
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
76
97
|
|
|
77
98
|
|
|
78
99
|
# === Honest imports from extracted homes ===================================
|
|
@@ -101,14 +122,26 @@ def _dispatch_alert_notification(
|
|
|
101
122
|
popen_factory=subprocess.Popen,
|
|
102
123
|
mode: str = "real",
|
|
103
124
|
tz: "object | None" = None,
|
|
125
|
+
platform: "str | None" = None,
|
|
126
|
+
which_on_path=None,
|
|
104
127
|
) -> str:
|
|
105
|
-
"""
|
|
128
|
+
"""Dispatch a notification for a crossed threshold (non-blocking, best-effort).
|
|
106
129
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
130
|
+
Picks the active notifier (osascript / notify-send / a config-driven
|
|
131
|
+
command_template / none) via the pure ``_lib_alert_dispatch`` kernel, builds
|
|
132
|
+
its exact arg-list, and spawns it with ``shell=False``. Returns one of:
|
|
133
|
+
``"queued"`` Popen succeeded
|
|
134
|
+
``"no_notifier:none"`` auto/none resolved to no popup on this host
|
|
135
|
+
``"no_notifier:unavailable"`` an explicit osascript/notify-send is missing
|
|
136
|
+
``"spawn_error: <ExcType>: <msg>"`` Popen raised
|
|
137
|
+
Writes EXACTLY ONE line to ``alerts.log`` with the terminal status PLUS the
|
|
138
|
+
crossing's 3-tier severity as a trailing column. Never raises: the config
|
|
139
|
+
read, Popen-spawn failures, and log-write failures are all swallowed so the
|
|
140
|
+
dispatch contract stays independent of the OS / FS / user-config state.
|
|
141
|
+
|
|
142
|
+
``platform`` (sys.platform-style) and ``which_on_path`` (name -> bool) are
|
|
143
|
+
injectable so every OS branch + the no-notifier paths are testable from any
|
|
144
|
+
host; both default to the real ``sys.platform`` / ``shutil.which``.
|
|
112
145
|
|
|
113
146
|
Production callers ignore the return value (fire-and-forget); test
|
|
114
147
|
callers assert on it via an injected ``popen_factory``.
|
|
@@ -138,6 +171,8 @@ def _dispatch_alert_notification(
|
|
|
138
171
|
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
139
172
|
elif axis == "budget":
|
|
140
173
|
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
174
|
+
elif axis == "projected":
|
|
175
|
+
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
141
176
|
else:
|
|
142
177
|
title, subtitle, body = (
|
|
143
178
|
"cctally - alert",
|
|
@@ -145,27 +180,64 @@ def _dispatch_alert_notification(
|
|
|
145
180
|
f"axis={axis} threshold={payload.get('threshold')}",
|
|
146
181
|
)
|
|
147
182
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
183
|
+
# Severity (3-tier) drives both the notify-send urgency token and the
|
|
184
|
+
# trailing log column. A missing threshold (defensive — shouldn't happen for
|
|
185
|
+
# a real crossing) floors at "info".
|
|
186
|
+
threshold = payload.get("threshold")
|
|
187
|
+
try:
|
|
188
|
+
severity = severity_for(int(threshold)) if threshold is not None else "info"
|
|
189
|
+
except (TypeError, ValueError):
|
|
190
|
+
severity = "info"
|
|
191
|
+
urgency = severity_to_urgency(severity)
|
|
192
|
+
|
|
193
|
+
if platform is None:
|
|
194
|
+
platform = sys.platform
|
|
195
|
+
if which_on_path is None:
|
|
196
|
+
which_on_path = lambda name: shutil.which(name) is not None
|
|
197
|
+
|
|
198
|
+
# Guarded so a malformed user config (or a load_config raise) never breaks
|
|
199
|
+
# the never-raise contract: fall back to auto-detect / no custom command.
|
|
200
|
+
try:
|
|
201
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
202
|
+
except Exception:
|
|
203
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
204
|
+
|
|
205
|
+
notifier = resolve_notifier(
|
|
206
|
+
alerts_cfg, platform=platform, which_on_path=which_on_path
|
|
207
|
+
)
|
|
208
|
+
args = build_command(
|
|
209
|
+
notifier,
|
|
210
|
+
title=title,
|
|
211
|
+
subtitle=subtitle,
|
|
212
|
+
body=body,
|
|
213
|
+
severity=severity,
|
|
214
|
+
urgency=urgency,
|
|
215
|
+
payload=payload,
|
|
216
|
+
command_template=alerts_cfg.get("command_template"),
|
|
152
217
|
)
|
|
153
218
|
|
|
154
219
|
status: str
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
220
|
+
if args is None:
|
|
221
|
+
# 'none' (auto resolved to no popup, or notifier='none') vs an
|
|
222
|
+
# explicitly-selected native backend that is unavailable on this host.
|
|
223
|
+
selector = alerts_cfg.get("notifier", "auto")
|
|
224
|
+
reason = "unavailable" if selector in ("osascript", "notify-send") else "none"
|
|
225
|
+
status = f"no_notifier:{reason}"
|
|
226
|
+
else:
|
|
227
|
+
try:
|
|
228
|
+
popen_factory(
|
|
229
|
+
args,
|
|
230
|
+
stdout=subprocess.DEVNULL,
|
|
231
|
+
stderr=subprocess.DEVNULL,
|
|
232
|
+
close_fds=True,
|
|
233
|
+
start_new_session=True,
|
|
234
|
+
)
|
|
235
|
+
status = "queued"
|
|
236
|
+
except (FileNotFoundError, PermissionError, OSError) as exc:
|
|
237
|
+
status = f"spawn_error: {exc.__class__.__name__}: {exc}"
|
|
166
238
|
|
|
167
|
-
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no
|
|
168
|
-
#
|
|
239
|
+
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no contradictory
|
|
240
|
+
# "queued" + "spawn_error" pair). Severity is appended as the 7th column.
|
|
169
241
|
try:
|
|
170
242
|
log_path = _alerts_log_path()
|
|
171
243
|
ctx = payload.get("context") or {}
|
|
@@ -177,7 +249,7 @@ def _dispatch_alert_notification(
|
|
|
177
249
|
)
|
|
178
250
|
line = (
|
|
179
251
|
f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
|
|
180
|
-
f"\t{mode}\t{status}\n"
|
|
252
|
+
f"\t{mode}\t{status}\t{severity}\n"
|
|
181
253
|
)
|
|
182
254
|
with open(log_path, "a") as f:
|
|
183
255
|
f.write(line)
|
|
@@ -207,6 +279,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
207
279
|
axis = "weekly"
|
|
208
280
|
elif args.axis == "budget":
|
|
209
281
|
axis = "budget"
|
|
282
|
+
elif args.axis == "projected":
|
|
283
|
+
axis = "projected"
|
|
210
284
|
else:
|
|
211
285
|
axis = "five_hour"
|
|
212
286
|
threshold = int(args.threshold)
|
|
@@ -239,6 +313,27 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
239
313
|
spent_usd=300.0 * threshold / 100.0,
|
|
240
314
|
consumption_pct=float(threshold),
|
|
241
315
|
)
|
|
316
|
+
elif axis == "projected":
|
|
317
|
+
# Synthetic projected-pace payload — NO DB writes (test/real divergence
|
|
318
|
+
# contract). The metric discriminator picks the wiring; projected_value
|
|
319
|
+
# is the threshold's denominator-relative value (so the body reads
|
|
320
|
+
# plausibly, e.g. weekly 100% → "~100% of cap", budget 100% → "$300 of
|
|
321
|
+
# $300"). denominator is the at-crossing target the row would carry
|
|
322
|
+
# (Codex P0-4): 100.0 for weekly_pct, $300 for budget_usd.
|
|
323
|
+
metric = getattr(args, "metric", "weekly_pct")
|
|
324
|
+
if metric == "budget_usd":
|
|
325
|
+
denominator = 300.0
|
|
326
|
+
projected_value = 300.0 * threshold / 100.0
|
|
327
|
+
else: # weekly_pct
|
|
328
|
+
denominator = 100.0
|
|
329
|
+
projected_value = float(threshold)
|
|
330
|
+
payload = _build_alert_payload_projected(
|
|
331
|
+
metric=metric,
|
|
332
|
+
threshold=threshold,
|
|
333
|
+
projected_value=projected_value,
|
|
334
|
+
denominator=denominator,
|
|
335
|
+
week_start_at=dt.date.today().isoformat(),
|
|
336
|
+
)
|
|
242
337
|
else:
|
|
243
338
|
payload = _build_alert_payload_five_hour(
|
|
244
339
|
threshold=threshold,
|
|
@@ -248,6 +343,20 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
248
343
|
block_cost_usd=1.23,
|
|
249
344
|
primary_model="claude-sonnet-4-6",
|
|
250
345
|
)
|
|
346
|
+
# Resolve and report the active notifier for display BEFORE dispatch — the
|
|
347
|
+
# config read is guarded the same way `_dispatch_alert_notification` guards
|
|
348
|
+
# its own (so a malformed config never crashes `alerts test`). This is
|
|
349
|
+
# purely informational; the dispatch path re-resolves independently.
|
|
350
|
+
try:
|
|
351
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
352
|
+
except Exception:
|
|
353
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
354
|
+
notifier = resolve_notifier(
|
|
355
|
+
alerts_cfg,
|
|
356
|
+
platform=sys.platform,
|
|
357
|
+
which_on_path=lambda name: shutil.which(name) is not None,
|
|
358
|
+
)
|
|
359
|
+
print(f"notifier: {notifier}")
|
|
251
360
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
252
361
|
if status == "queued":
|
|
253
362
|
print("Test alert dispatched (mode=test). Check Notification Center.")
|
package/bin/_cctally_config.py
CHANGED
|
@@ -297,6 +297,9 @@ def save_config(data: dict[str, Any]) -> None:
|
|
|
297
297
|
ALLOWED_CONFIG_KEYS = (
|
|
298
298
|
"display.tz",
|
|
299
299
|
"alerts.enabled",
|
|
300
|
+
"alerts.projected_enabled",
|
|
301
|
+
"alerts.notifier",
|
|
302
|
+
"alerts.command_template",
|
|
300
303
|
"dashboard.bind",
|
|
301
304
|
"update.check.enabled",
|
|
302
305
|
"update.check.ttl_hours",
|
|
@@ -306,6 +309,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
306
309
|
"budget.weekly_usd",
|
|
307
310
|
"budget.alerts_enabled",
|
|
308
311
|
"budget.alert_thresholds",
|
|
312
|
+
"budget.projected_enabled",
|
|
309
313
|
)
|
|
310
314
|
|
|
311
315
|
|
|
@@ -405,6 +409,28 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
405
409
|
return c.get_display_tz_pref(config)
|
|
406
410
|
if key == "alerts.enabled":
|
|
407
411
|
return bool(_get_alerts_config(config)["enabled"])
|
|
412
|
+
if key == "alerts.projected_enabled":
|
|
413
|
+
# Validated boolean (defaults to False when unset). A corrupt alerts
|
|
414
|
+
# block surfaces the default — mirrors alerts.enabled.
|
|
415
|
+
try:
|
|
416
|
+
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
417
|
+
except c._AlertsConfigError:
|
|
418
|
+
return False
|
|
419
|
+
if key == "alerts.notifier":
|
|
420
|
+
# Validated dispatch backend (defaults to 'auto' when unset). A corrupt
|
|
421
|
+
# alerts block surfaces the default — mirrors alerts.enabled.
|
|
422
|
+
try:
|
|
423
|
+
return _get_alerts_config(config)["notifier"]
|
|
424
|
+
except c._AlertsConfigError:
|
|
425
|
+
return "auto"
|
|
426
|
+
if key == "alerts.command_template":
|
|
427
|
+
# Validated argv list or None (defaults to None when unset). A corrupt
|
|
428
|
+
# alerts block surfaces the default. The plain-text render path JSON-
|
|
429
|
+
# encodes this so `config get` round-trips through `config set`.
|
|
430
|
+
try:
|
|
431
|
+
return _get_alerts_config(config)["command_template"]
|
|
432
|
+
except c._AlertsConfigError:
|
|
433
|
+
return None
|
|
408
434
|
if key == "dashboard.bind":
|
|
409
435
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
410
436
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -472,6 +498,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
472
498
|
"budget.weekly_usd",
|
|
473
499
|
"budget.alerts_enabled",
|
|
474
500
|
"budget.alert_thresholds",
|
|
501
|
+
"budget.projected_enabled",
|
|
475
502
|
):
|
|
476
503
|
inner = key.split(".", 1)[1]
|
|
477
504
|
# Read the validated, defaults-filled block. A corrupt block falls
|
|
@@ -495,14 +522,20 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
495
522
|
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
496
523
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
497
524
|
return 2
|
|
525
|
+
# `alerts.command_template` is JSON-shaped (a list of strings or null), so
|
|
526
|
+
# its real value (including None) must survive into the render layer — the
|
|
527
|
+
# generic None->"" coercion below would break the JSON shape / round-trip.
|
|
528
|
+
def _coerce(k: str, v: "object") -> "object":
|
|
529
|
+
if k == "alerts.command_template":
|
|
530
|
+
return v
|
|
531
|
+
return v if v is not None else ""
|
|
532
|
+
|
|
498
533
|
pairs: "list[tuple[str, object]]" = []
|
|
499
534
|
if key is None:
|
|
500
535
|
for k in ALLOWED_CONFIG_KEYS:
|
|
501
|
-
|
|
502
|
-
pairs.append((k, v if v is not None else ""))
|
|
536
|
+
pairs.append((k, _coerce(k, _config_known_value(config, k))))
|
|
503
537
|
else:
|
|
504
|
-
|
|
505
|
-
pairs.append((key, v if v is not None else ""))
|
|
538
|
+
pairs.append((key, _coerce(key, _config_known_value(config, key))))
|
|
506
539
|
|
|
507
540
|
if getattr(args, "emit_json", False):
|
|
508
541
|
# Walk every dot-delimited segment so keys deeper than two
|
|
@@ -522,7 +555,12 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
522
555
|
for k, v in pairs:
|
|
523
556
|
# Preserve canonical bool stringification (true/false) so
|
|
524
557
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
525
|
-
if
|
|
558
|
+
if k == "alerts.command_template":
|
|
559
|
+
# JSON-encoded (list of strings or null) so `config get` output
|
|
560
|
+
# round-trips through `config set alerts.command_template`
|
|
561
|
+
# (which JSON-parses its value).
|
|
562
|
+
rendered = json.dumps(v)
|
|
563
|
+
elif isinstance(v, bool):
|
|
526
564
|
rendered = "true" if v else "false"
|
|
527
565
|
elif isinstance(v, list):
|
|
528
566
|
# Comma-joined so `config get budget.alert_thresholds` output
|
|
@@ -598,6 +636,121 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
598
636
|
else:
|
|
599
637
|
print(f"alerts.enabled={'true' if normalized else 'false'}")
|
|
600
638
|
return 0
|
|
639
|
+
if key == "alerts.projected_enabled":
|
|
640
|
+
# Projected-pace opt-in (#121). Same bool-normalizer + read-modify-write
|
|
641
|
+
# posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
642
|
+
# _normalize_alerts_enabled_value hardcodes "alerts.enabled" in its
|
|
643
|
+
# ValueError text, so catch + re-message with the actual key name
|
|
644
|
+
# (mirrors _normalize_update_check_enabled_value's precedent) — the
|
|
645
|
+
# budget side already names its own key correctly.
|
|
646
|
+
try:
|
|
647
|
+
normalized = c._normalize_alerts_enabled_value(raw)
|
|
648
|
+
except ValueError:
|
|
649
|
+
print(
|
|
650
|
+
f"cctally: invalid boolean value for alerts.projected_enabled: "
|
|
651
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
652
|
+
file=sys.stderr,
|
|
653
|
+
)
|
|
654
|
+
return 2
|
|
655
|
+
with config_writer_lock():
|
|
656
|
+
config = _load_config_unlocked()
|
|
657
|
+
existing_alerts = config.get("alerts")
|
|
658
|
+
if existing_alerts is not None and not isinstance(
|
|
659
|
+
existing_alerts, dict
|
|
660
|
+
):
|
|
661
|
+
print(
|
|
662
|
+
"cctally: alerts config error: alerts must be an object",
|
|
663
|
+
file=sys.stderr,
|
|
664
|
+
)
|
|
665
|
+
return 2
|
|
666
|
+
alerts_block = dict(existing_alerts or {})
|
|
667
|
+
alerts_block["projected_enabled"] = normalized
|
|
668
|
+
try:
|
|
669
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
670
|
+
except _AlertsConfigError as exc:
|
|
671
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
672
|
+
return 2
|
|
673
|
+
config["alerts"] = alerts_block
|
|
674
|
+
save_config(config)
|
|
675
|
+
if getattr(args, "emit_json", False):
|
|
676
|
+
print(
|
|
677
|
+
json.dumps({"alerts": {"projected_enabled": normalized}}, indent=2)
|
|
678
|
+
)
|
|
679
|
+
else:
|
|
680
|
+
print(
|
|
681
|
+
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
682
|
+
)
|
|
683
|
+
return 0
|
|
684
|
+
if key == "alerts.notifier":
|
|
685
|
+
# Dispatch backend (Phase B). Plain string; the enum constraint is
|
|
686
|
+
# enforced by the pre-persist _get_alerts_config validation (so we never
|
|
687
|
+
# write a config that fails subsequent reads). Same read-modify-write
|
|
688
|
+
# posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
689
|
+
normalized = raw.strip()
|
|
690
|
+
with config_writer_lock():
|
|
691
|
+
config = _load_config_unlocked()
|
|
692
|
+
existing_alerts = config.get("alerts")
|
|
693
|
+
if existing_alerts is not None and not isinstance(
|
|
694
|
+
existing_alerts, dict
|
|
695
|
+
):
|
|
696
|
+
print(
|
|
697
|
+
"cctally: alerts config error: alerts must be an object",
|
|
698
|
+
file=sys.stderr,
|
|
699
|
+
)
|
|
700
|
+
return 2
|
|
701
|
+
alerts_block = dict(existing_alerts or {})
|
|
702
|
+
alerts_block["notifier"] = normalized
|
|
703
|
+
try:
|
|
704
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
705
|
+
except _AlertsConfigError as exc:
|
|
706
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
707
|
+
return 2
|
|
708
|
+
config["alerts"] = alerts_block
|
|
709
|
+
save_config(config)
|
|
710
|
+
if getattr(args, "emit_json", False):
|
|
711
|
+
print(json.dumps({"alerts": {"notifier": normalized}}, indent=2))
|
|
712
|
+
else:
|
|
713
|
+
print(f"alerts.notifier={normalized}")
|
|
714
|
+
return 0
|
|
715
|
+
if key == "alerts.command_template":
|
|
716
|
+
# Dispatch argv template (Phase B). JSON-parsed value (a list of strings
|
|
717
|
+
# or null to clear it); the shape + cross-field constraints are enforced
|
|
718
|
+
# by the pre-persist _get_alerts_config validation. Same read-modify-
|
|
719
|
+
# write posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
720
|
+
try:
|
|
721
|
+
parsed = json.loads(raw)
|
|
722
|
+
except (ValueError, TypeError) as exc:
|
|
723
|
+
print(
|
|
724
|
+
f"cctally: alerts.command_template must be JSON (a list of "
|
|
725
|
+
f"strings or null): {exc}",
|
|
726
|
+
file=sys.stderr,
|
|
727
|
+
)
|
|
728
|
+
return 2
|
|
729
|
+
with config_writer_lock():
|
|
730
|
+
config = _load_config_unlocked()
|
|
731
|
+
existing_alerts = config.get("alerts")
|
|
732
|
+
if existing_alerts is not None and not isinstance(
|
|
733
|
+
existing_alerts, dict
|
|
734
|
+
):
|
|
735
|
+
print(
|
|
736
|
+
"cctally: alerts config error: alerts must be an object",
|
|
737
|
+
file=sys.stderr,
|
|
738
|
+
)
|
|
739
|
+
return 2
|
|
740
|
+
alerts_block = dict(existing_alerts or {})
|
|
741
|
+
alerts_block["command_template"] = parsed
|
|
742
|
+
try:
|
|
743
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
744
|
+
except _AlertsConfigError as exc:
|
|
745
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
746
|
+
return 2
|
|
747
|
+
config["alerts"] = alerts_block
|
|
748
|
+
save_config(config)
|
|
749
|
+
if getattr(args, "emit_json", False):
|
|
750
|
+
print(json.dumps({"alerts": {"command_template": parsed}}, indent=2))
|
|
751
|
+
else:
|
|
752
|
+
print(f"alerts.command_template={json.dumps(parsed)}")
|
|
753
|
+
return 0
|
|
601
754
|
if key == "dashboard.bind":
|
|
602
755
|
# Validation rejects whitespace / empty / non-string up front;
|
|
603
756
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -715,6 +868,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
715
868
|
"budget.weekly_usd",
|
|
716
869
|
"budget.alerts_enabled",
|
|
717
870
|
"budget.alert_thresholds",
|
|
871
|
+
"budget.projected_enabled",
|
|
718
872
|
):
|
|
719
873
|
inner_key = key.split(".", 1)[1]
|
|
720
874
|
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
@@ -734,7 +888,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
734
888
|
f"null, got {raw!r}"
|
|
735
889
|
)
|
|
736
890
|
return 2
|
|
737
|
-
elif inner_key
|
|
891
|
+
elif inner_key in ("alerts_enabled", "projected_enabled"):
|
|
738
892
|
lo = raw.strip().lower()
|
|
739
893
|
if lo in ("true", "yes", "on", "1"):
|
|
740
894
|
new_val = True
|
|
@@ -742,7 +896,7 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
742
896
|
new_val = False
|
|
743
897
|
else:
|
|
744
898
|
eprint(
|
|
745
|
-
"cctally config: budget.
|
|
899
|
+
f"cctally config: budget.{inner_key} must be a boolean, "
|
|
746
900
|
f"got {raw!r}"
|
|
747
901
|
)
|
|
748
902
|
return 2
|
|
@@ -817,21 +971,47 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
817
971
|
save_config(config)
|
|
818
972
|
# idempotent: silent on missing key
|
|
819
973
|
return 0
|
|
820
|
-
if key
|
|
974
|
+
if key in (
|
|
975
|
+
"alerts.enabled",
|
|
976
|
+
"alerts.projected_enabled",
|
|
977
|
+
"alerts.notifier",
|
|
978
|
+
"alerts.command_template",
|
|
979
|
+
):
|
|
821
980
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
822
981
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
|
823
982
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
824
|
-
#
|
|
825
|
-
# (`weekly_thresholds`, `five_hour_thresholds`)
|
|
826
|
-
#
|
|
827
|
-
#
|
|
983
|
+
# named key preserves any user-customized threshold lists
|
|
984
|
+
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
985
|
+
# enabled/projected_enabled/notifier/command_template keys. For
|
|
986
|
+
# enabled/projected_enabled/notifier the read-time validator
|
|
987
|
+
# (`_get_alerts_config`) re-applies the canonical default
|
|
988
|
+
# (`False` / `"auto"`) for the missing key on next get. NOT so for
|
|
989
|
+
# command_template when notifier == "command": the cross-field
|
|
990
|
+
# constraint makes notifier="command" REQUIRE a template, so dropping
|
|
991
|
+
# the template would leave a config that _get_alerts_config REJECTS on
|
|
992
|
+
# the next read. The pre-persist guard below catches exactly that case.
|
|
993
|
+
inner_key = key.split(".", 1)[1]
|
|
828
994
|
with config_writer_lock():
|
|
829
995
|
config = _load_config_unlocked()
|
|
830
996
|
block = config.get("alerts")
|
|
831
|
-
if isinstance(block, dict) and
|
|
832
|
-
del block[
|
|
997
|
+
if isinstance(block, dict) and inner_key in block:
|
|
998
|
+
del block[inner_key]
|
|
833
999
|
if not block:
|
|
834
1000
|
config.pop("alerts", None)
|
|
1001
|
+
# Pre-persist guard (mirrors the set branches): unsetting a key
|
|
1002
|
+
# that participates in a cross-field constraint
|
|
1003
|
+
# (alerts.command_template while alerts.notifier == "command")
|
|
1004
|
+
# would leave a config that _get_alerts_config rejects on the
|
|
1005
|
+
# next read. Validate the TOP-LEVEL config (so a pruned/empty
|
|
1006
|
+
# alerts block correctly validates to defaults) and refuse
|
|
1007
|
+
# rather than persist an unreadable config.
|
|
1008
|
+
try:
|
|
1009
|
+
_get_alerts_config(config)
|
|
1010
|
+
except _AlertsConfigError as exc:
|
|
1011
|
+
print(
|
|
1012
|
+
f"cctally: alerts config error: {exc}", file=sys.stderr
|
|
1013
|
+
)
|
|
1014
|
+
return 2
|
|
835
1015
|
save_config(config)
|
|
836
1016
|
# idempotent: silent on missing key
|
|
837
1017
|
return 0
|
|
@@ -889,6 +1069,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
889
1069
|
"budget.weekly_usd",
|
|
890
1070
|
"budget.alerts_enabled",
|
|
891
1071
|
"budget.alert_thresholds",
|
|
1072
|
+
"budget.projected_enabled",
|
|
892
1073
|
):
|
|
893
1074
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
894
1075
|
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|