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 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
@@ -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
- """Spawn osascript to display a macOS notification (non-blocking, best-effort).
128
+ """Dispatch a notification for a crossed threshold (non-blocking, best-effort).
106
129
 
107
- Returns ``"queued"`` on successful Popen, ``"spawn_error: <ExcType>: <msg>"``
108
- on failure. Writes EXACTLY ONE line to ``alerts.log`` with the terminal
109
- status (no contradictory pre-/post-Popen log pair). Never raises:
110
- Popen-spawn failures and log-write failures are both swallowed so the
111
- dispatch contract stays independent of the OS / FS state.
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
- script = (
149
- f'display notification "{_escape_applescript_string(body)}"'
150
- f' with title "{_escape_applescript_string(title)}"'
151
- f' subtitle "{_escape_applescript_string(subtitle)}"'
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
- try:
156
- popen_factory(
157
- ["osascript", "-e", script],
158
- stdout=subprocess.DEVNULL,
159
- stderr=subprocess.DEVNULL,
160
- close_fds=True,
161
- start_new_session=True,
162
- )
163
- status = "queued"
164
- except (FileNotFoundError, PermissionError, OSError) as exc:
165
- status = f"spawn_error: {exc.__class__.__name__}: {exc}"
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
- # contradictory "queued" + "spawn_error" pair for the same call).
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.")
@@ -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
- v = _config_known_value(config, k)
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
- v = _config_known_value(config, key)
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 isinstance(v, bool):
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 == "alerts_enabled":
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.alerts_enabled must be a boolean, "
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 == "alerts.enabled":
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
- # `enabled` key preserves any user-customized threshold lists
825
- # (`weekly_thresholds`, `five_hour_thresholds`); the read-time
826
- # validator (`_get_alerts_config`) re-applies the canonical
827
- # default of `enabled = False` for the missing key on next get.
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 "enabled" in block:
832
- del block["enabled"]
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