cctally 1.23.0 → 1.25.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 +14 -0
- package/bin/_cctally_alerts.py +128 -24
- package/bin/_cctally_config.py +202 -11
- package/bin/_cctally_core.py +118 -0
- package/bin/_cctally_dashboard.py +193 -26
- package/bin/_cctally_forecast.py +480 -16
- package/bin/_cctally_milestones.py +146 -0
- package/bin/_cctally_parser.py +11 -4
- package/bin/_cctally_project.py +51 -0
- package/bin/_cctally_record.py +227 -1
- package/bin/_lib_alert_axes.py +21 -7
- package/bin/_lib_alert_dispatch.py +141 -0
- package/bin/_lib_alerts_payload.py +70 -0
- package/bin/cctally +19 -0
- package/dashboard/static/assets/index-C2F1_Mxt.js +18 -0
- package/dashboard/static/assets/{index-ZHOC14y-.css → index-D34qf0LE.css} +1 -1
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +2 -1
- package/dashboard/static/assets/index-CXZDQrV3.js +0 -18
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.25.0] - 2026-06-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Per-project weekly budgets with their own actual-spend alerts (a new `project_budget` alert axis).** Set a dollar budget for any repo with `cctally budget set 25 --project` (resolves the current directory's git-root) or `--project /abs/path`, clear it with `cctally budget unset --project`, and `cctally budget` now renders a per-project section below the global status (budget / spent / used% / verdict / `LOW CONF`, sorted by used% desc) — present even when no global `budget.weekly_usd` is set, additive in `--json` (a `projects[]` array, no schema bump) and in share-output (names anonymized unless `--reveal-projects`). Turn on push alerts (opt-in, default off) with `cctally config set budget.project_alerts_enabled true`: when a project crosses one of your `budget.alert_thresholds` of its own budget, `record-usage` fires one cross-platform notification per `(project, threshold)` per week with project-specific text (e.g. *"Project foo - $26.00 of $25.00 (104% of budget)"*), severity from the shared 3-tier model. Setting a project budget mid-week when you're already over a threshold records that crossing silently (no retroactive popup) and only later crossings fire, and a mid-week budget change never re-alerts an already-fired threshold. Preview the notification without any real config via `cctally alerts test --axis project-budget --threshold 100`. Thresholds reuse the global `budget.alert_thresholds`; projects are keyed by canonical git-root so same-basename repos stay distinct. In the local web dashboard, fired project alerts now show in the existing "Recent alerts" panel/modal with a distinct **PROJECT** chip (vs the global **BUDGET** chip) and the project basename + `$spent of $budget` context, the Settings overlay gains a **Per-project budget alerts** on/off toggle that persists `budget.project_alerts_enabled` (enabling it mid-week when already over latches the crossed thresholds without storming retroactive popups), and the Settings "Send test alert" picker can fire the synthetic project-budget example end-to-end; editing per-project budget *amounts* stays CLI-only.
|
|
12
|
+
|
|
13
|
+
## [1.24.0] - 2026-06-02
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- **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.
|
|
17
|
+
- **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`.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- **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.
|
|
21
|
+
|
|
8
22
|
## [1.23.0] - 2026-06-02
|
|
9
23
|
|
|
10
24
|
### Added
|
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,13 +70,33 @@ _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_project_budget = _lib_alerts_payload._alert_text_project_budget
|
|
72
74
|
_alert_text_projected = _lib_alerts_payload._alert_text_projected
|
|
73
75
|
_escape_applescript_string = _lib_alerts_payload._escape_applescript_string
|
|
74
76
|
_build_alert_payload_weekly = _lib_alerts_payload._build_alert_payload_weekly
|
|
75
77
|
_build_alert_payload_five_hour = _lib_alerts_payload._build_alert_payload_five_hour
|
|
76
78
|
_build_alert_payload_budget = _lib_alerts_payload._build_alert_payload_budget
|
|
79
|
+
_build_alert_payload_project_budget = _lib_alerts_payload._build_alert_payload_project_budget
|
|
77
80
|
_build_alert_payload_projected = _lib_alerts_payload._build_alert_payload_projected
|
|
78
81
|
|
|
82
|
+
# Phase B: severity policy + the cross-platform dispatch kernel. The kernel is
|
|
83
|
+
# pure (parameterized on platform + which_on_path); this module is the I/O glue
|
|
84
|
+
# that injects the real sys.platform / shutil.which and spawns with shell=False.
|
|
85
|
+
_lib_alert_axes = _load_lib("_lib_alert_axes")
|
|
86
|
+
severity_for = _lib_alert_axes.severity_for
|
|
87
|
+
_lib_alert_dispatch = _load_lib("_lib_alert_dispatch")
|
|
88
|
+
resolve_notifier = _lib_alert_dispatch.resolve_notifier
|
|
89
|
+
build_command = _lib_alert_dispatch.build_command
|
|
90
|
+
severity_to_urgency = _lib_alert_dispatch.severity_to_urgency
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# `load_config` STAYS a shim that bounces through cctally's namespace (mirrors
|
|
94
|
+
# bin/_cctally_record.py): production monkeypatches `cctally.load_config`, and
|
|
95
|
+
# the dispatch tests patch this module-level name directly. Its natural home is
|
|
96
|
+
# _cctally_config; a direct import would silently bypass those patches.
|
|
97
|
+
def load_config(*args, **kwargs):
|
|
98
|
+
return sys.modules["cctally"].load_config(*args, **kwargs)
|
|
99
|
+
|
|
79
100
|
|
|
80
101
|
# === Honest imports from extracted homes ===================================
|
|
81
102
|
# Spec 2026-05-17 §3.3: kernel symbols import from _cctally_core.
|
|
@@ -103,14 +124,26 @@ def _dispatch_alert_notification(
|
|
|
103
124
|
popen_factory=subprocess.Popen,
|
|
104
125
|
mode: str = "real",
|
|
105
126
|
tz: "object | None" = None,
|
|
127
|
+
platform: "str | None" = None,
|
|
128
|
+
which_on_path=None,
|
|
106
129
|
) -> str:
|
|
107
|
-
"""
|
|
130
|
+
"""Dispatch a notification for a crossed threshold (non-blocking, best-effort).
|
|
108
131
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
Picks the active notifier (osascript / notify-send / a config-driven
|
|
133
|
+
command_template / none) via the pure ``_lib_alert_dispatch`` kernel, builds
|
|
134
|
+
its exact arg-list, and spawns it with ``shell=False``. Returns one of:
|
|
135
|
+
``"queued"`` Popen succeeded
|
|
136
|
+
``"no_notifier:none"`` auto/none resolved to no popup on this host
|
|
137
|
+
``"no_notifier:unavailable"`` an explicit osascript/notify-send is missing
|
|
138
|
+
``"spawn_error: <ExcType>: <msg>"`` Popen raised
|
|
139
|
+
Writes EXACTLY ONE line to ``alerts.log`` with the terminal status PLUS the
|
|
140
|
+
crossing's 3-tier severity as a trailing column. Never raises: the config
|
|
141
|
+
read, Popen-spawn failures, and log-write failures are all swallowed so the
|
|
142
|
+
dispatch contract stays independent of the OS / FS / user-config state.
|
|
143
|
+
|
|
144
|
+
``platform`` (sys.platform-style) and ``which_on_path`` (name -> bool) are
|
|
145
|
+
injectable so every OS branch + the no-notifier paths are testable from any
|
|
146
|
+
host; both default to the real ``sys.platform`` / ``shutil.which``.
|
|
114
147
|
|
|
115
148
|
Production callers ignore the return value (fire-and-forget); test
|
|
116
149
|
callers assert on it via an injected ``popen_factory``.
|
|
@@ -140,6 +173,8 @@ def _dispatch_alert_notification(
|
|
|
140
173
|
title, subtitle, body = _alert_text_five_hour(payload, tz)
|
|
141
174
|
elif axis == "budget":
|
|
142
175
|
title, subtitle, body = _alert_text_budget(payload, tz)
|
|
176
|
+
elif axis == "project_budget":
|
|
177
|
+
title, subtitle, body = _alert_text_project_budget(payload, tz)
|
|
143
178
|
elif axis == "projected":
|
|
144
179
|
title, subtitle, body = _alert_text_projected(payload, tz)
|
|
145
180
|
else:
|
|
@@ -149,27 +184,64 @@ def _dispatch_alert_notification(
|
|
|
149
184
|
f"axis={axis} threshold={payload.get('threshold')}",
|
|
150
185
|
)
|
|
151
186
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
187
|
+
# Severity (3-tier) drives both the notify-send urgency token and the
|
|
188
|
+
# trailing log column. A missing threshold (defensive — shouldn't happen for
|
|
189
|
+
# a real crossing) floors at "info".
|
|
190
|
+
threshold = payload.get("threshold")
|
|
191
|
+
try:
|
|
192
|
+
severity = severity_for(int(threshold)) if threshold is not None else "info"
|
|
193
|
+
except (TypeError, ValueError):
|
|
194
|
+
severity = "info"
|
|
195
|
+
urgency = severity_to_urgency(severity)
|
|
196
|
+
|
|
197
|
+
if platform is None:
|
|
198
|
+
platform = sys.platform
|
|
199
|
+
if which_on_path is None:
|
|
200
|
+
which_on_path = lambda name: shutil.which(name) is not None
|
|
201
|
+
|
|
202
|
+
# Guarded so a malformed user config (or a load_config raise) never breaks
|
|
203
|
+
# the never-raise contract: fall back to auto-detect / no custom command.
|
|
204
|
+
try:
|
|
205
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
206
|
+
except Exception:
|
|
207
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
208
|
+
|
|
209
|
+
notifier = resolve_notifier(
|
|
210
|
+
alerts_cfg, platform=platform, which_on_path=which_on_path
|
|
211
|
+
)
|
|
212
|
+
args = build_command(
|
|
213
|
+
notifier,
|
|
214
|
+
title=title,
|
|
215
|
+
subtitle=subtitle,
|
|
216
|
+
body=body,
|
|
217
|
+
severity=severity,
|
|
218
|
+
urgency=urgency,
|
|
219
|
+
payload=payload,
|
|
220
|
+
command_template=alerts_cfg.get("command_template"),
|
|
156
221
|
)
|
|
157
222
|
|
|
158
223
|
status: str
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
224
|
+
if args is None:
|
|
225
|
+
# 'none' (auto resolved to no popup, or notifier='none') vs an
|
|
226
|
+
# explicitly-selected native backend that is unavailable on this host.
|
|
227
|
+
selector = alerts_cfg.get("notifier", "auto")
|
|
228
|
+
reason = "unavailable" if selector in ("osascript", "notify-send") else "none"
|
|
229
|
+
status = f"no_notifier:{reason}"
|
|
230
|
+
else:
|
|
231
|
+
try:
|
|
232
|
+
popen_factory(
|
|
233
|
+
args,
|
|
234
|
+
stdout=subprocess.DEVNULL,
|
|
235
|
+
stderr=subprocess.DEVNULL,
|
|
236
|
+
close_fds=True,
|
|
237
|
+
start_new_session=True,
|
|
238
|
+
)
|
|
239
|
+
status = "queued"
|
|
240
|
+
except (FileNotFoundError, PermissionError, OSError) as exc:
|
|
241
|
+
status = f"spawn_error: {exc.__class__.__name__}: {exc}"
|
|
170
242
|
|
|
171
|
-
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no
|
|
172
|
-
#
|
|
243
|
+
# SINGLE log line per dispatch attempt (Codex P1#2 fix: no contradictory
|
|
244
|
+
# "queued" + "spawn_error" pair). Severity is appended as the 7th column.
|
|
173
245
|
try:
|
|
174
246
|
log_path = _alerts_log_path()
|
|
175
247
|
ctx = payload.get("context") or {}
|
|
@@ -181,7 +253,7 @@ def _dispatch_alert_notification(
|
|
|
181
253
|
)
|
|
182
254
|
line = (
|
|
183
255
|
f"{now_utc_iso()}\t{axis}\t{payload.get('threshold')}\t{window_key}"
|
|
184
|
-
f"\t{mode}\t{status}\n"
|
|
256
|
+
f"\t{mode}\t{status}\t{severity}\n"
|
|
185
257
|
)
|
|
186
258
|
with open(log_path, "a") as f:
|
|
187
259
|
f.write(line)
|
|
@@ -211,6 +283,8 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
211
283
|
axis = "weekly"
|
|
212
284
|
elif args.axis == "budget":
|
|
213
285
|
axis = "budget"
|
|
286
|
+
elif args.axis == "project-budget":
|
|
287
|
+
axis = "project_budget"
|
|
214
288
|
elif args.axis == "projected":
|
|
215
289
|
axis = "projected"
|
|
216
290
|
else:
|
|
@@ -245,6 +319,22 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
245
319
|
spent_usd=300.0 * threshold / 100.0,
|
|
246
320
|
consumption_pct=float(threshold),
|
|
247
321
|
)
|
|
322
|
+
elif axis == "project_budget":
|
|
323
|
+
# Synthetic per-project budget payload — NO DB writes (test/real
|
|
324
|
+
# divergence contract), NO real budget.projects entry required. A small
|
|
325
|
+
# $25 budget at $26 spent (104%) reads plausibly regardless of the
|
|
326
|
+
# --threshold (the body line shows the at-crossing snapshot the dashboard
|
|
327
|
+
# would render).
|
|
328
|
+
payload = _build_alert_payload_project_budget(
|
|
329
|
+
threshold=threshold,
|
|
330
|
+
crossed_at_utc=now_utc_iso(),
|
|
331
|
+
week_start_at=dt.date.today().isoformat(),
|
|
332
|
+
project="example-project",
|
|
333
|
+
project_key="/example/repos/example-project",
|
|
334
|
+
budget_usd=25.0,
|
|
335
|
+
spent_usd=26.0,
|
|
336
|
+
consumption_pct=104.0,
|
|
337
|
+
)
|
|
248
338
|
elif axis == "projected":
|
|
249
339
|
# Synthetic projected-pace payload — NO DB writes (test/real divergence
|
|
250
340
|
# contract). The metric discriminator picks the wiring; projected_value
|
|
@@ -275,6 +365,20 @@ def cmd_alerts_test(args: argparse.Namespace) -> int:
|
|
|
275
365
|
block_cost_usd=1.23,
|
|
276
366
|
primary_model="claude-sonnet-4-6",
|
|
277
367
|
)
|
|
368
|
+
# Resolve and report the active notifier for display BEFORE dispatch — the
|
|
369
|
+
# config read is guarded the same way `_dispatch_alert_notification` guards
|
|
370
|
+
# its own (so a malformed config never crashes `alerts test`). This is
|
|
371
|
+
# purely informational; the dispatch path re-resolves independently.
|
|
372
|
+
try:
|
|
373
|
+
alerts_cfg = _cctally_core._get_alerts_config(load_config())
|
|
374
|
+
except Exception:
|
|
375
|
+
alerts_cfg = {"notifier": "auto", "command_template": None}
|
|
376
|
+
notifier = resolve_notifier(
|
|
377
|
+
alerts_cfg,
|
|
378
|
+
platform=sys.platform,
|
|
379
|
+
which_on_path=lambda name: shutil.which(name) is not None,
|
|
380
|
+
)
|
|
381
|
+
print(f"notifier: {notifier}")
|
|
278
382
|
status = _dispatch_alert_notification(payload, mode="test")
|
|
279
383
|
if status == "queued":
|
|
280
384
|
print("Test alert dispatched (mode=test). Check Notification Center.")
|
package/bin/_cctally_config.py
CHANGED
|
@@ -298,6 +298,8 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
298
298
|
"display.tz",
|
|
299
299
|
"alerts.enabled",
|
|
300
300
|
"alerts.projected_enabled",
|
|
301
|
+
"alerts.notifier",
|
|
302
|
+
"alerts.command_template",
|
|
301
303
|
"dashboard.bind",
|
|
302
304
|
"update.check.enabled",
|
|
303
305
|
"update.check.ttl_hours",
|
|
@@ -308,6 +310,8 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
308
310
|
"budget.alerts_enabled",
|
|
309
311
|
"budget.alert_thresholds",
|
|
310
312
|
"budget.projected_enabled",
|
|
313
|
+
"budget.projects",
|
|
314
|
+
"budget.project_alerts_enabled",
|
|
311
315
|
)
|
|
312
316
|
|
|
313
317
|
|
|
@@ -414,6 +418,21 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
414
418
|
return bool(_get_alerts_config(config)["projected_enabled"])
|
|
415
419
|
except c._AlertsConfigError:
|
|
416
420
|
return False
|
|
421
|
+
if key == "alerts.notifier":
|
|
422
|
+
# Validated dispatch backend (defaults to 'auto' when unset). A corrupt
|
|
423
|
+
# alerts block surfaces the default — mirrors alerts.enabled.
|
|
424
|
+
try:
|
|
425
|
+
return _get_alerts_config(config)["notifier"]
|
|
426
|
+
except c._AlertsConfigError:
|
|
427
|
+
return "auto"
|
|
428
|
+
if key == "alerts.command_template":
|
|
429
|
+
# Validated argv list or None (defaults to None when unset). A corrupt
|
|
430
|
+
# alerts block surfaces the default. The plain-text render path JSON-
|
|
431
|
+
# encodes this so `config get` round-trips through `config set`.
|
|
432
|
+
try:
|
|
433
|
+
return _get_alerts_config(config)["command_template"]
|
|
434
|
+
except c._AlertsConfigError:
|
|
435
|
+
return None
|
|
417
436
|
if key == "dashboard.bind":
|
|
418
437
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
419
438
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -482,6 +501,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
482
501
|
"budget.alerts_enabled",
|
|
483
502
|
"budget.alert_thresholds",
|
|
484
503
|
"budget.projected_enabled",
|
|
504
|
+
"budget.projects",
|
|
505
|
+
"budget.project_alerts_enabled",
|
|
485
506
|
):
|
|
486
507
|
inner = key.split(".", 1)[1]
|
|
487
508
|
# Read the validated, defaults-filled block. A corrupt block falls
|
|
@@ -496,6 +517,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
496
517
|
default = _BUDGET_DEFAULTS[inner]
|
|
497
518
|
if isinstance(default, list):
|
|
498
519
|
return list(default)
|
|
520
|
+
if isinstance(default, dict):
|
|
521
|
+
return dict(default)
|
|
499
522
|
return default
|
|
500
523
|
return None
|
|
501
524
|
|
|
@@ -505,14 +528,21 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
505
528
|
if key is not None and key not in ALLOWED_CONFIG_KEYS:
|
|
506
529
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
507
530
|
return 2
|
|
531
|
+
# `alerts.command_template` is JSON-shaped (a list of strings or null), and
|
|
532
|
+
# `budget.projects` is JSON-shaped (an object), so their real values
|
|
533
|
+
# (including None) must survive into the render layer — the generic
|
|
534
|
+
# None->"" coercion below would break the JSON shape / round-trip.
|
|
535
|
+
def _coerce(k: str, v: "object") -> "object":
|
|
536
|
+
if k in ("alerts.command_template", "budget.projects"):
|
|
537
|
+
return v
|
|
538
|
+
return v if v is not None else ""
|
|
539
|
+
|
|
508
540
|
pairs: "list[tuple[str, object]]" = []
|
|
509
541
|
if key is None:
|
|
510
542
|
for k in ALLOWED_CONFIG_KEYS:
|
|
511
|
-
|
|
512
|
-
pairs.append((k, v if v is not None else ""))
|
|
543
|
+
pairs.append((k, _coerce(k, _config_known_value(config, k))))
|
|
513
544
|
else:
|
|
514
|
-
|
|
515
|
-
pairs.append((key, v if v is not None else ""))
|
|
545
|
+
pairs.append((key, _coerce(key, _config_known_value(config, key))))
|
|
516
546
|
|
|
517
547
|
if getattr(args, "emit_json", False):
|
|
518
548
|
# Walk every dot-delimited segment so keys deeper than two
|
|
@@ -532,7 +562,13 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
532
562
|
for k, v in pairs:
|
|
533
563
|
# Preserve canonical bool stringification (true/false) so
|
|
534
564
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
535
|
-
if
|
|
565
|
+
if k in ("alerts.command_template", "budget.projects"):
|
|
566
|
+
# JSON-encoded so `config get` output round-trips through the
|
|
567
|
+
# matching `config set` branch (both JSON-parse their value).
|
|
568
|
+
# `alerts.command_template` is a list-of-strings|null;
|
|
569
|
+
# `budget.projects` is an object {git-root: usd}.
|
|
570
|
+
rendered = json.dumps(v)
|
|
571
|
+
elif isinstance(v, bool):
|
|
536
572
|
rendered = "true" if v else "false"
|
|
537
573
|
elif isinstance(v, list):
|
|
538
574
|
# Comma-joined so `config get budget.alert_thresholds` output
|
|
@@ -653,6 +689,76 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
653
689
|
f"alerts.projected_enabled={'true' if normalized else 'false'}"
|
|
654
690
|
)
|
|
655
691
|
return 0
|
|
692
|
+
if key == "alerts.notifier":
|
|
693
|
+
# Dispatch backend (Phase B). Plain string; the enum constraint is
|
|
694
|
+
# enforced by the pre-persist _get_alerts_config validation (so we never
|
|
695
|
+
# write a config that fails subsequent reads). Same read-modify-write
|
|
696
|
+
# posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
697
|
+
normalized = raw.strip()
|
|
698
|
+
with config_writer_lock():
|
|
699
|
+
config = _load_config_unlocked()
|
|
700
|
+
existing_alerts = config.get("alerts")
|
|
701
|
+
if existing_alerts is not None and not isinstance(
|
|
702
|
+
existing_alerts, dict
|
|
703
|
+
):
|
|
704
|
+
print(
|
|
705
|
+
"cctally: alerts config error: alerts must be an object",
|
|
706
|
+
file=sys.stderr,
|
|
707
|
+
)
|
|
708
|
+
return 2
|
|
709
|
+
alerts_block = dict(existing_alerts or {})
|
|
710
|
+
alerts_block["notifier"] = normalized
|
|
711
|
+
try:
|
|
712
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
713
|
+
except _AlertsConfigError as exc:
|
|
714
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
715
|
+
return 2
|
|
716
|
+
config["alerts"] = alerts_block
|
|
717
|
+
save_config(config)
|
|
718
|
+
if getattr(args, "emit_json", False):
|
|
719
|
+
print(json.dumps({"alerts": {"notifier": normalized}}, indent=2))
|
|
720
|
+
else:
|
|
721
|
+
print(f"alerts.notifier={normalized}")
|
|
722
|
+
return 0
|
|
723
|
+
if key == "alerts.command_template":
|
|
724
|
+
# Dispatch argv template (Phase B). JSON-parsed value (a list of strings
|
|
725
|
+
# or null to clear it); the shape + cross-field constraints are enforced
|
|
726
|
+
# by the pre-persist _get_alerts_config validation. Same read-modify-
|
|
727
|
+
# write posture as alerts.enabled (preserves sibling alerts.* keys).
|
|
728
|
+
try:
|
|
729
|
+
parsed = json.loads(raw)
|
|
730
|
+
except (ValueError, TypeError) as exc:
|
|
731
|
+
print(
|
|
732
|
+
f"cctally: alerts.command_template must be JSON (a list of "
|
|
733
|
+
f"strings or null): {exc}",
|
|
734
|
+
file=sys.stderr,
|
|
735
|
+
)
|
|
736
|
+
return 2
|
|
737
|
+
with config_writer_lock():
|
|
738
|
+
config = _load_config_unlocked()
|
|
739
|
+
existing_alerts = config.get("alerts")
|
|
740
|
+
if existing_alerts is not None and not isinstance(
|
|
741
|
+
existing_alerts, dict
|
|
742
|
+
):
|
|
743
|
+
print(
|
|
744
|
+
"cctally: alerts config error: alerts must be an object",
|
|
745
|
+
file=sys.stderr,
|
|
746
|
+
)
|
|
747
|
+
return 2
|
|
748
|
+
alerts_block = dict(existing_alerts or {})
|
|
749
|
+
alerts_block["command_template"] = parsed
|
|
750
|
+
try:
|
|
751
|
+
_get_alerts_config({**config, "alerts": alerts_block})
|
|
752
|
+
except _AlertsConfigError as exc:
|
|
753
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
754
|
+
return 2
|
|
755
|
+
config["alerts"] = alerts_block
|
|
756
|
+
save_config(config)
|
|
757
|
+
if getattr(args, "emit_json", False):
|
|
758
|
+
print(json.dumps({"alerts": {"command_template": parsed}}, indent=2))
|
|
759
|
+
else:
|
|
760
|
+
print(f"alerts.command_template={json.dumps(parsed)}")
|
|
761
|
+
return 0
|
|
656
762
|
if key == "dashboard.bind":
|
|
657
763
|
# Validation rejects whitespace / empty / non-string up front;
|
|
658
764
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -771,6 +877,8 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
771
877
|
"budget.alerts_enabled",
|
|
772
878
|
"budget.alert_thresholds",
|
|
773
879
|
"budget.projected_enabled",
|
|
880
|
+
"budget.projects",
|
|
881
|
+
"budget.project_alerts_enabled",
|
|
774
882
|
):
|
|
775
883
|
inner_key = key.split(".", 1)[1]
|
|
776
884
|
# Parse + normalize the raw value per key BEFORE acquiring the lock so
|
|
@@ -790,7 +898,9 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
790
898
|
f"null, got {raw!r}"
|
|
791
899
|
)
|
|
792
900
|
return 2
|
|
793
|
-
elif inner_key in (
|
|
901
|
+
elif inner_key in (
|
|
902
|
+
"alerts_enabled", "projected_enabled", "project_alerts_enabled"
|
|
903
|
+
):
|
|
794
904
|
lo = raw.strip().lower()
|
|
795
905
|
if lo in ("true", "yes", "on", "1"):
|
|
796
906
|
new_val = True
|
|
@@ -802,6 +912,42 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
802
912
|
f"got {raw!r}"
|
|
803
913
|
)
|
|
804
914
|
return 2
|
|
915
|
+
elif inner_key == "projects":
|
|
916
|
+
# `budget.projects` is a dict {git-root: usd}, which the plain
|
|
917
|
+
# number/bool/list leaves can't round-trip — JSON-parse it (mirrors
|
|
918
|
+
# the alerts.command_template branch). The per-value numeric rule is
|
|
919
|
+
# enforced by _get_budget_config under the lock below; here we only
|
|
920
|
+
# reject non-JSON / non-object shape.
|
|
921
|
+
try:
|
|
922
|
+
parsed_obj = json.loads(raw)
|
|
923
|
+
except (json.JSONDecodeError, ValueError):
|
|
924
|
+
eprint(
|
|
925
|
+
"cctally config: budget.projects must be a JSON object, "
|
|
926
|
+
f"got {raw!r}"
|
|
927
|
+
)
|
|
928
|
+
return 2
|
|
929
|
+
if not isinstance(parsed_obj, dict):
|
|
930
|
+
eprint("cctally config: budget.projects must be a JSON object")
|
|
931
|
+
return 2
|
|
932
|
+
# Canonicalize each project key to its resolved git-root, mirroring
|
|
933
|
+
# the `budget set --project` CLI path (`_resolve_project_budget_-
|
|
934
|
+
# target`). `_sum_cost_by_project` buckets spend under the realpath'd
|
|
935
|
+
# `ProjectKey.bucket_path`, so a `~`/relative/sub-dir/trailing-slash
|
|
936
|
+
# key stored verbatim would NEVER match → a permanent $0 row that
|
|
937
|
+
# silently never alerts. Resolving here makes the JSON-object surface
|
|
938
|
+
# match the per-project CLI surface. Non-string keys (impossible from
|
|
939
|
+
# json.loads, defensive) and the `__CWD__`-non-git None case fall
|
|
940
|
+
# back to the raw key for `_get_budget_config` to handle.
|
|
941
|
+
c = _cctally()
|
|
942
|
+
new_val = {
|
|
943
|
+
(
|
|
944
|
+
c._resolve_project_budget_target(pk)
|
|
945
|
+
if isinstance(pk, str)
|
|
946
|
+
else pk
|
|
947
|
+
)
|
|
948
|
+
or pk: pv
|
|
949
|
+
for pk, pv in parsed_obj.items()
|
|
950
|
+
}
|
|
805
951
|
else: # alert_thresholds — comma-separated int list (empty = silenced)
|
|
806
952
|
stripped = raw.strip()
|
|
807
953
|
parsed: "list[int]" = []
|
|
@@ -843,13 +989,32 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
843
989
|
# helper opens stats.db and must not nest under the config lock
|
|
844
990
|
# (fcntl.flock is per-fd; the helper has its own open_db locking).
|
|
845
991
|
c = _cctally()
|
|
846
|
-
|
|
992
|
+
# Gate each forward-only reconcile (spec §6.8) on the keys it actually
|
|
993
|
+
# consumes. Running unconditionally on an UNRELATED write — e.g. the
|
|
994
|
+
# global axis on `config set budget.projects`, or the per-project axis
|
|
995
|
+
# on `budget.weekly_usd` — would latch a currently-over-but-not-yet-
|
|
996
|
+
# dispatched threshold as already-alerted, permanently suppressing the
|
|
997
|
+
# next record-usage tick's dispatch. The global axis feeds on
|
|
998
|
+
# weekly_usd/alerts_enabled/alert_thresholds; the per-project axis on
|
|
999
|
+
# projects/project_alerts_enabled/alert_thresholds (alert_thresholds is
|
|
1000
|
+
# shared; projected_enabled belongs to neither reconcile). Both run
|
|
1001
|
+
# OUTSIDE config_writer_lock (each helper has its own open_db lock).
|
|
1002
|
+
if inner_key in ("weekly_usd", "alerts_enabled", "alert_thresholds"):
|
|
1003
|
+
c._reconcile_budget_on_config_write(validated)
|
|
1004
|
+
if inner_key in (
|
|
1005
|
+
"projects", "project_alerts_enabled", "alert_thresholds"
|
|
1006
|
+
):
|
|
1007
|
+
c._reconcile_project_budget_milestones_on_write(validated)
|
|
847
1008
|
out_val = validated[inner_key]
|
|
848
1009
|
if getattr(args, "emit_json", False):
|
|
849
1010
|
print(json.dumps({"budget": {inner_key: out_val}}, indent=2))
|
|
850
1011
|
else:
|
|
851
1012
|
if isinstance(out_val, bool):
|
|
852
1013
|
rendered = "true" if out_val else "false"
|
|
1014
|
+
elif inner_key == "projects":
|
|
1015
|
+
# JSON so `config get budget.projects` round-trips back through
|
|
1016
|
+
# this branch (str(dict) is not valid JSON).
|
|
1017
|
+
rendered = json.dumps(out_val)
|
|
853
1018
|
else:
|
|
854
1019
|
rendered = str(out_val)
|
|
855
1020
|
print(f"{key}={rendered}")
|
|
@@ -873,15 +1038,25 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
873
1038
|
save_config(config)
|
|
874
1039
|
# idempotent: silent on missing key
|
|
875
1040
|
return 0
|
|
876
|
-
if key in (
|
|
1041
|
+
if key in (
|
|
1042
|
+
"alerts.enabled",
|
|
1043
|
+
"alerts.projected_enabled",
|
|
1044
|
+
"alerts.notifier",
|
|
1045
|
+
"alerts.command_template",
|
|
1046
|
+
):
|
|
877
1047
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
878
1048
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|
|
879
1049
|
# self-deadlock per the gotcha in CLAUDE.md). Unsetting just the
|
|
880
1050
|
# named key preserves any user-customized threshold lists
|
|
881
1051
|
# (`weekly_thresholds`, `five_hour_thresholds`) and the sibling
|
|
882
|
-
# enabled/projected_enabled
|
|
883
|
-
#
|
|
884
|
-
#
|
|
1052
|
+
# enabled/projected_enabled/notifier/command_template keys. For
|
|
1053
|
+
# enabled/projected_enabled/notifier the read-time validator
|
|
1054
|
+
# (`_get_alerts_config`) re-applies the canonical default
|
|
1055
|
+
# (`False` / `"auto"`) for the missing key on next get. NOT so for
|
|
1056
|
+
# command_template when notifier == "command": the cross-field
|
|
1057
|
+
# constraint makes notifier="command" REQUIRE a template, so dropping
|
|
1058
|
+
# the template would leave a config that _get_alerts_config REJECTS on
|
|
1059
|
+
# the next read. The pre-persist guard below catches exactly that case.
|
|
885
1060
|
inner_key = key.split(".", 1)[1]
|
|
886
1061
|
with config_writer_lock():
|
|
887
1062
|
config = _load_config_unlocked()
|
|
@@ -890,6 +1065,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
890
1065
|
del block[inner_key]
|
|
891
1066
|
if not block:
|
|
892
1067
|
config.pop("alerts", None)
|
|
1068
|
+
# Pre-persist guard (mirrors the set branches): unsetting a key
|
|
1069
|
+
# that participates in a cross-field constraint
|
|
1070
|
+
# (alerts.command_template while alerts.notifier == "command")
|
|
1071
|
+
# would leave a config that _get_alerts_config rejects on the
|
|
1072
|
+
# next read. Validate the TOP-LEVEL config (so a pruned/empty
|
|
1073
|
+
# alerts block correctly validates to defaults) and refuse
|
|
1074
|
+
# rather than persist an unreadable config.
|
|
1075
|
+
try:
|
|
1076
|
+
_get_alerts_config(config)
|
|
1077
|
+
except _AlertsConfigError as exc:
|
|
1078
|
+
print(
|
|
1079
|
+
f"cctally: alerts config error: {exc}", file=sys.stderr
|
|
1080
|
+
)
|
|
1081
|
+
return 2
|
|
893
1082
|
save_config(config)
|
|
894
1083
|
# idempotent: silent on missing key
|
|
895
1084
|
return 0
|
|
@@ -948,6 +1137,8 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
948
1137
|
"budget.alerts_enabled",
|
|
949
1138
|
"budget.alert_thresholds",
|
|
950
1139
|
"budget.projected_enabled",
|
|
1140
|
+
"budget.projects",
|
|
1141
|
+
"budget.project_alerts_enabled",
|
|
951
1142
|
):
|
|
952
1143
|
# Drop only the named leaf; preserve sibling budget.* keys (e.g.
|
|
953
1144
|
# unsetting weekly_usd keeps a customized alert_thresholds). If the
|