cctally 1.67.1 → 1.69.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 +68 -0
- package/bin/_cctally_alerts.py +54 -2
- package/bin/_cctally_cache.py +754 -109
- package/bin/_cctally_cache_report.py +41 -17
- package/bin/_cctally_codex.py +109 -4
- package/bin/_cctally_config.py +368 -2
- package/bin/_cctally_core.py +187 -15
- package/bin/_cctally_dashboard.py +679 -5
- package/bin/_cctally_dashboard_conversation.py +9 -4
- package/bin/_cctally_dashboard_envelope.py +110 -1
- package/bin/_cctally_dashboard_share.py +557 -79
- package/bin/_cctally_db.py +366 -17
- package/bin/_cctally_diff.py +49 -16
- package/bin/_cctally_doctor.py +131 -0
- package/bin/_cctally_forecast.py +12 -4
- package/bin/_cctally_parser.py +321 -92
- package/bin/_cctally_project.py +24 -8
- package/bin/_cctally_quota.py +1381 -0
- package/bin/_cctally_record.py +319 -14
- package/bin/_cctally_refresh.py +105 -3
- package/bin/_cctally_reporting.py +7 -1
- package/bin/_cctally_setup.py +343 -113
- package/bin/_cctally_statusline.py +228 -0
- package/bin/_cctally_tui.py +787 -7
- package/bin/_lib_alert_axes.py +11 -0
- package/bin/_lib_codex_hooks.py +367 -0
- package/bin/_lib_conversation_query.py +156 -67
- package/bin/_lib_doctor.py +238 -0
- package/bin/_lib_jsonl.py +529 -162
- package/bin/_lib_quota.py +566 -0
- package/bin/_lib_share.py +152 -34
- package/bin/_lib_snapshot_cache.py +26 -0
- package/bin/_lib_source_identity.py +74 -0
- package/bin/cctally +325 -10
- package/dashboard/static/assets/index-CBbErI-P.js +80 -0
- package/dashboard/static/assets/index-kDDVOLa_.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-BybNp_Di.js +0 -80
- package/dashboard/static/assets/index-DgAmLK65.css +0 -1
package/bin/_cctally_config.py
CHANGED
|
@@ -75,6 +75,8 @@ from _cctally_core import (
|
|
|
75
75
|
_AlertsConfigError,
|
|
76
76
|
_get_budget_config,
|
|
77
77
|
_BudgetConfigError,
|
|
78
|
+
_validate_codex_budget_block,
|
|
79
|
+
CODEX_BUDGET_LEAVES,
|
|
78
80
|
)
|
|
79
81
|
from _lib_display_tz import normalize_display_tz_value
|
|
80
82
|
|
|
@@ -300,6 +302,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
300
302
|
"alerts.projected_enabled",
|
|
301
303
|
"alerts.notifier",
|
|
302
304
|
"alerts.command_template",
|
|
305
|
+
"alerts.quota",
|
|
303
306
|
"dashboard.bind",
|
|
304
307
|
"dashboard.expose_transcripts",
|
|
305
308
|
"dashboard.cache_failure_markers",
|
|
@@ -318,10 +321,236 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
318
321
|
"budget.projects",
|
|
319
322
|
"budget.project_alerts_enabled",
|
|
320
323
|
"budget.codex",
|
|
324
|
+
"budget.codex.amount_usd",
|
|
325
|
+
"budget.codex.period",
|
|
326
|
+
"budget.codex.alerts_enabled",
|
|
327
|
+
"budget.codex.alert_thresholds",
|
|
328
|
+
"budget.codex.projected_enabled",
|
|
321
329
|
"telemetry.enabled",
|
|
322
330
|
)
|
|
323
331
|
|
|
324
332
|
|
|
333
|
+
_CODEX_BUDGET_LEAF_PREFIX = "budget.codex."
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _config_codex_leaf_value(config: dict, key: str) -> object:
|
|
337
|
+
"""Read one nested Codex budget leaf with its public default.
|
|
338
|
+
|
|
339
|
+
``config get`` is deliberately forgiving of a hand-edited corrupt block,
|
|
340
|
+
just like the existing whole-object ``budget.codex`` read: it reports the
|
|
341
|
+
defaults without repairing the file. Mutations use the strict setter below.
|
|
342
|
+
"""
|
|
343
|
+
if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
344
|
+
raise ValueError(f"not a Codex budget leaf: {key!r}")
|
|
345
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
346
|
+
if leaf not in CODEX_BUDGET_LEAVES:
|
|
347
|
+
raise ValueError(f"unknown Codex budget leaf: {key!r}")
|
|
348
|
+
try:
|
|
349
|
+
block = _get_budget_config(config)["codex"]
|
|
350
|
+
except _BudgetConfigError:
|
|
351
|
+
block = None
|
|
352
|
+
if block is None:
|
|
353
|
+
defaults = {
|
|
354
|
+
"amount_usd": None,
|
|
355
|
+
"period": "calendar-month",
|
|
356
|
+
"alerts_enabled": False,
|
|
357
|
+
"alert_thresholds": [90, 100],
|
|
358
|
+
"projected_enabled": False,
|
|
359
|
+
}
|
|
360
|
+
value = defaults[leaf]
|
|
361
|
+
else:
|
|
362
|
+
value = block[leaf]
|
|
363
|
+
return list(value) if isinstance(value, list) else value
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _parse_codex_budget_leaf_value(leaf: str, raw_value: str) -> object:
|
|
367
|
+
"""Parse exactly the command-line wire format for one Codex leaf."""
|
|
368
|
+
if leaf == "amount_usd":
|
|
369
|
+
try:
|
|
370
|
+
return float(raw_value)
|
|
371
|
+
except ValueError as exc:
|
|
372
|
+
raise _BudgetConfigError(
|
|
373
|
+
"budget.codex.amount_usd must be a finite number > 0"
|
|
374
|
+
) from exc
|
|
375
|
+
if leaf == "period":
|
|
376
|
+
return raw_value.strip()
|
|
377
|
+
if leaf in {"alerts_enabled", "projected_enabled"}:
|
|
378
|
+
normalized = raw_value.strip().lower()
|
|
379
|
+
if normalized in {"true", "yes", "on", "1"}:
|
|
380
|
+
return True
|
|
381
|
+
if normalized in {"false", "no", "off", "0"}:
|
|
382
|
+
return False
|
|
383
|
+
raise _BudgetConfigError(f"budget.codex.{leaf} must be a boolean")
|
|
384
|
+
if leaf == "alert_thresholds":
|
|
385
|
+
if not raw_value.strip():
|
|
386
|
+
return []
|
|
387
|
+
parsed: list[int] = []
|
|
388
|
+
for part in raw_value.split(","):
|
|
389
|
+
try:
|
|
390
|
+
parsed.append(int(part.strip(), 10))
|
|
391
|
+
except ValueError as exc:
|
|
392
|
+
raise _BudgetConfigError(
|
|
393
|
+
"budget.codex.alert_thresholds must be a comma-separated "
|
|
394
|
+
"list of integers"
|
|
395
|
+
) from exc
|
|
396
|
+
return parsed
|
|
397
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {leaf!r}")
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _set_codex_budget_leaf(config: dict, key: str, raw_value: str) -> dict:
|
|
401
|
+
"""Return the canonical prospective nested Codex budget block.
|
|
402
|
+
|
|
403
|
+
The caller owns the writer lock and persistence. This pure merge helper
|
|
404
|
+
validates existing state before mutating it, so malformed user state never
|
|
405
|
+
becomes a partially repaired write.
|
|
406
|
+
"""
|
|
407
|
+
if not key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
408
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
|
|
409
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
410
|
+
if leaf not in CODEX_BUDGET_LEAVES:
|
|
411
|
+
raise _BudgetConfigError(f"unknown Codex budget leaf {key!r}")
|
|
412
|
+
budget = config.get("budget")
|
|
413
|
+
if budget is not None and not isinstance(budget, dict):
|
|
414
|
+
raise _BudgetConfigError("budget must be an object")
|
|
415
|
+
existing = (budget or {}).get("codex")
|
|
416
|
+
if existing is None:
|
|
417
|
+
if leaf != "amount_usd":
|
|
418
|
+
raise _BudgetConfigError(
|
|
419
|
+
"budget.codex.amount_usd must be configured before setting "
|
|
420
|
+
f"budget.codex.{leaf}"
|
|
421
|
+
)
|
|
422
|
+
prospective: dict = {}
|
|
423
|
+
else:
|
|
424
|
+
if not isinstance(existing, dict):
|
|
425
|
+
raise _BudgetConfigError(
|
|
426
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
427
|
+
)
|
|
428
|
+
# Strictly validate first: malformed existing data must abort without
|
|
429
|
+
# mutation, while valid unknown siblings retain the validator's
|
|
430
|
+
# existing warn-and-drop behavior when the prospective block is built.
|
|
431
|
+
validated_existing = _validate_codex_budget_block(existing)
|
|
432
|
+
assert validated_existing is not None
|
|
433
|
+
prospective = dict(validated_existing)
|
|
434
|
+
prospective[leaf] = _parse_codex_budget_leaf_value(leaf, raw_value)
|
|
435
|
+
validated = _validate_codex_budget_block(prospective)
|
|
436
|
+
assert validated is not None
|
|
437
|
+
return validated
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
# ``alerts.quota`` is an additive nested object owned by the provider-neutral
|
|
441
|
+
# quota projection. Keep the existing core alert reader byte-compatible for
|
|
442
|
+
# Claude axes while making this now-known child exempt from its unknown-key
|
|
443
|
+
# warning path.
|
|
444
|
+
_cctally_core._ALERTS_CONFIG_VALID_KEYS.add("quota")
|
|
445
|
+
|
|
446
|
+
_QUOTA_ALERT_KEYS = {
|
|
447
|
+
"enabled", "actual_thresholds", "projected_thresholds", "rules",
|
|
448
|
+
}
|
|
449
|
+
_QUOTA_RULE_KEYS = {
|
|
450
|
+
"source", "source_root_key", "logical_limit_key",
|
|
451
|
+
"actual_thresholds", "projected_thresholds",
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _quota_alert_error(message: str) -> None:
|
|
456
|
+
raise _cctally_core._AlertsConfigError(message)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _validate_quota_thresholds(name: str, value: object) -> list[int]:
|
|
460
|
+
"""Validate a quota list; unlike legacy axis lists, [] deliberately silences."""
|
|
461
|
+
if not isinstance(value, list):
|
|
462
|
+
_quota_alert_error(f"alerts.quota.{name} must be a list of integers")
|
|
463
|
+
result: list[int] = []
|
|
464
|
+
prior = 0
|
|
465
|
+
for item in value:
|
|
466
|
+
if not isinstance(item, int) or isinstance(item, bool):
|
|
467
|
+
_quota_alert_error(
|
|
468
|
+
f"alerts.quota.{name} items must be integers, got "
|
|
469
|
+
f"{type(item).__name__}: {item!r}"
|
|
470
|
+
)
|
|
471
|
+
if not 1 <= item <= 100:
|
|
472
|
+
_quota_alert_error(
|
|
473
|
+
f"alerts.quota.{name} items must be in [1, 100], got {item}"
|
|
474
|
+
)
|
|
475
|
+
if item <= prior:
|
|
476
|
+
_quota_alert_error(f"alerts.quota.{name} must be strictly increasing")
|
|
477
|
+
result.append(item)
|
|
478
|
+
prior = item
|
|
479
|
+
return result
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _get_quota_alerts_config(cfg: "dict | None") -> dict:
|
|
483
|
+
"""Return the strict, defaults-filled ``alerts.quota`` configuration."""
|
|
484
|
+
alerts = (cfg or {}).get("alerts", {})
|
|
485
|
+
if alerts is None:
|
|
486
|
+
alerts = {}
|
|
487
|
+
if not isinstance(alerts, dict):
|
|
488
|
+
_quota_alert_error("alerts must be an object")
|
|
489
|
+
quota = alerts.get("quota", {})
|
|
490
|
+
if quota is None or not isinstance(quota, dict):
|
|
491
|
+
_quota_alert_error("alerts.quota must be an object")
|
|
492
|
+
for key in quota:
|
|
493
|
+
if key not in _QUOTA_ALERT_KEYS:
|
|
494
|
+
print(
|
|
495
|
+
f"warning: ignoring unknown alerts.quota config key: {key}",
|
|
496
|
+
file=sys.stderr,
|
|
497
|
+
)
|
|
498
|
+
enabled = quota.get("enabled", False)
|
|
499
|
+
if not isinstance(enabled, bool):
|
|
500
|
+
_quota_alert_error(
|
|
501
|
+
"alerts.quota.enabled must be a JSON boolean, got "
|
|
502
|
+
f"{type(enabled).__name__}: {enabled!r}"
|
|
503
|
+
)
|
|
504
|
+
actual = _validate_quota_thresholds(
|
|
505
|
+
"actual_thresholds", quota.get("actual_thresholds", [90, 95])
|
|
506
|
+
)
|
|
507
|
+
projected = _validate_quota_thresholds(
|
|
508
|
+
"projected_thresholds", quota.get("projected_thresholds", [])
|
|
509
|
+
)
|
|
510
|
+
raw_rules = quota.get("rules", [])
|
|
511
|
+
if not isinstance(raw_rules, list):
|
|
512
|
+
_quota_alert_error("alerts.quota.rules must be a list")
|
|
513
|
+
rules: list[dict] = []
|
|
514
|
+
seen: set[tuple[str, str, str]] = set()
|
|
515
|
+
for index, raw_rule in enumerate(raw_rules):
|
|
516
|
+
prefix = f"alerts.quota.rules[{index}]"
|
|
517
|
+
if not isinstance(raw_rule, dict):
|
|
518
|
+
_quota_alert_error(f"{prefix} must be an object")
|
|
519
|
+
if set(raw_rule) != _QUOTA_RULE_KEYS:
|
|
520
|
+
_quota_alert_error(
|
|
521
|
+
f"{prefix} must contain exactly {sorted(_QUOTA_RULE_KEYS)}"
|
|
522
|
+
)
|
|
523
|
+
normalized: dict[str, object] = {}
|
|
524
|
+
for key in ("source", "source_root_key", "logical_limit_key"):
|
|
525
|
+
value = raw_rule[key]
|
|
526
|
+
if not isinstance(value, str) or not value:
|
|
527
|
+
_quota_alert_error(f"{prefix}.{key} must be a non-empty string")
|
|
528
|
+
normalized[key] = value
|
|
529
|
+
normalized["actual_thresholds"] = _validate_quota_thresholds(
|
|
530
|
+
f"rules[{index}].actual_thresholds", raw_rule["actual_thresholds"]
|
|
531
|
+
)
|
|
532
|
+
normalized["projected_thresholds"] = _validate_quota_thresholds(
|
|
533
|
+
f"rules[{index}].projected_thresholds", raw_rule["projected_thresholds"]
|
|
534
|
+
)
|
|
535
|
+
identity = (
|
|
536
|
+
str(normalized["source"]), str(normalized["source_root_key"]),
|
|
537
|
+
str(normalized["logical_limit_key"]),
|
|
538
|
+
)
|
|
539
|
+
if identity in seen:
|
|
540
|
+
_quota_alert_error(
|
|
541
|
+
"alerts.quota.rules must be unique by source, source_root_key, "
|
|
542
|
+
"and logical_limit_key"
|
|
543
|
+
)
|
|
544
|
+
seen.add(identity)
|
|
545
|
+
rules.append(normalized)
|
|
546
|
+
return {
|
|
547
|
+
"enabled": enabled,
|
|
548
|
+
"actual_thresholds": actual,
|
|
549
|
+
"projected_thresholds": projected,
|
|
550
|
+
"rules": rules,
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
|
|
325
554
|
# === statusline config validators (issue #86 Session G) ===================
|
|
326
555
|
|
|
327
556
|
_STATUSLINE_VBR_VALUES = ("off", "emoji", "text", "emoji-text")
|
|
@@ -459,6 +688,11 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
459
688
|
return _get_alerts_config(config)["command_template"]
|
|
460
689
|
except c._AlertsConfigError:
|
|
461
690
|
return None
|
|
691
|
+
if key == "alerts.quota":
|
|
692
|
+
try:
|
|
693
|
+
return _get_quota_alerts_config(config)
|
|
694
|
+
except c._AlertsConfigError:
|
|
695
|
+
return _get_quota_alerts_config({})
|
|
462
696
|
if key == "dashboard.bind":
|
|
463
697
|
# Default semantic alias is 'loopback' (resolves to 127.0.0.1 at
|
|
464
698
|
# bind time). LAN exposure is opt-in via `set dashboard.bind lan`
|
|
@@ -580,6 +814,8 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
580
814
|
return c._validate_update_check_ttl_hours_value(stored)
|
|
581
815
|
except ValueError:
|
|
582
816
|
return c.UPDATE_DEFAULT_TTL_HOURS
|
|
817
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
818
|
+
return _config_codex_leaf_value(config, key)
|
|
583
819
|
if key in (
|
|
584
820
|
"statusline.visual_burn_rate",
|
|
585
821
|
"statusline.cost_source",
|
|
@@ -649,7 +885,10 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
649
885
|
# (including None) must survive into the render layer — the generic
|
|
650
886
|
# None->"" coercion below would break the JSON shape / round-trip.
|
|
651
887
|
def _coerce(k: str, v: "object") -> "object":
|
|
652
|
-
if k in (
|
|
888
|
+
if k in (
|
|
889
|
+
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
890
|
+
"budget.codex",
|
|
891
|
+
) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
653
892
|
return v
|
|
654
893
|
return v if v is not None else ""
|
|
655
894
|
|
|
@@ -667,7 +906,19 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
667
906
|
# a flat tail (`{"update": {"check.enabled": ...}}`) and
|
|
668
907
|
# diverged from `config set --json` / on-disk shape.
|
|
669
908
|
out: "dict[str, object]" = {}
|
|
909
|
+
nested_parents = {
|
|
910
|
+
candidate
|
|
911
|
+
for candidate, _value in pairs
|
|
912
|
+
if any(child.startswith(candidate + ".") for child, _value in pairs)
|
|
913
|
+
}
|
|
670
914
|
for k, v in pairs:
|
|
915
|
+
# A bulk read includes both `budget.codex` (null when not
|
|
916
|
+
# configured) and its additive `budget.codex.*` leaves. A scalar
|
|
917
|
+
# parent cannot coexist with those children in JSON, so let the
|
|
918
|
+
# specific leaves build their object. Direct `config get
|
|
919
|
+
# budget.codex --json` has no descendant pair and remains null.
|
|
920
|
+
if k in nested_parents and not isinstance(v, dict):
|
|
921
|
+
continue
|
|
671
922
|
segments = k.split(".")
|
|
672
923
|
node: dict = out
|
|
673
924
|
for seg in segments[:-1]:
|
|
@@ -679,7 +930,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
679
930
|
# Preserve canonical bool stringification (true/false) so
|
|
680
931
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
681
932
|
if k in (
|
|
682
|
-
"alerts.command_template", "
|
|
933
|
+
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
934
|
+
"budget.codex",
|
|
683
935
|
):
|
|
684
936
|
# JSON-encoded so `config get` output round-trips through the
|
|
685
937
|
# matching `config set` branch (both JSON-parse their value).
|
|
@@ -687,6 +939,8 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
687
939
|
# `budget.projects` is an object {git-root: usd};
|
|
688
940
|
# `budget.codex` is an object|null (the no-budget sentinel).
|
|
689
941
|
rendered = json.dumps(v)
|
|
942
|
+
elif k.startswith(_CODEX_BUDGET_LEAF_PREFIX) and v is None:
|
|
943
|
+
rendered = "null"
|
|
690
944
|
elif isinstance(v, bool):
|
|
691
945
|
rendered = "true" if v else "false"
|
|
692
946
|
elif isinstance(v, list):
|
|
@@ -705,6 +959,36 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
705
959
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
706
960
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
707
961
|
return 2
|
|
962
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
963
|
+
try:
|
|
964
|
+
with config_writer_lock():
|
|
965
|
+
config = _load_config_unlocked()
|
|
966
|
+
configured = _set_codex_budget_leaf(config, key, raw)
|
|
967
|
+
budget = config.get("budget")
|
|
968
|
+
if budget is not None and not isinstance(budget, dict):
|
|
969
|
+
raise _BudgetConfigError("budget must be an object")
|
|
970
|
+
block = dict(budget or {})
|
|
971
|
+
block["codex"] = configured
|
|
972
|
+
config["budget"] = block
|
|
973
|
+
save_config(config)
|
|
974
|
+
except _BudgetConfigError as exc:
|
|
975
|
+
eprint(f"cctally config: {exc}")
|
|
976
|
+
return 2
|
|
977
|
+
# Deliberately outside the config lock: this helper opens and locks DBs.
|
|
978
|
+
c._reconcile_codex_budget_on_config_write({"codex": configured})
|
|
979
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
980
|
+
value = configured[leaf]
|
|
981
|
+
if getattr(args, "emit_json", False):
|
|
982
|
+
print(json.dumps({"budget": {"codex": {leaf: value}}}, indent=2))
|
|
983
|
+
else:
|
|
984
|
+
if isinstance(value, bool):
|
|
985
|
+
rendered = "true" if value else "false"
|
|
986
|
+
elif isinstance(value, list):
|
|
987
|
+
rendered = ",".join(str(item) for item in value)
|
|
988
|
+
else:
|
|
989
|
+
rendered = str(value)
|
|
990
|
+
print(f"{key}={rendered}")
|
|
991
|
+
return 0
|
|
708
992
|
if key == "display.tz":
|
|
709
993
|
try:
|
|
710
994
|
canonical = normalize_display_tz_value(raw)
|
|
@@ -878,6 +1162,44 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
878
1162
|
else:
|
|
879
1163
|
print(f"alerts.command_template={json.dumps(parsed)}")
|
|
880
1164
|
return 0
|
|
1165
|
+
if key == "alerts.quota":
|
|
1166
|
+
try:
|
|
1167
|
+
parsed = json.loads(raw)
|
|
1168
|
+
except (ValueError, TypeError) as exc:
|
|
1169
|
+
print(
|
|
1170
|
+
f"cctally: alerts.quota must be a JSON object: {exc}",
|
|
1171
|
+
file=sys.stderr,
|
|
1172
|
+
)
|
|
1173
|
+
return 2
|
|
1174
|
+
if not isinstance(parsed, dict):
|
|
1175
|
+
print("cctally: alerts.quota must be a JSON object", file=sys.stderr)
|
|
1176
|
+
return 2
|
|
1177
|
+
with config_writer_lock():
|
|
1178
|
+
config = _load_config_unlocked()
|
|
1179
|
+
existing_alerts = config.get("alerts")
|
|
1180
|
+
if existing_alerts is not None and not isinstance(existing_alerts, dict):
|
|
1181
|
+
print(
|
|
1182
|
+
"cctally: alerts config error: alerts must be an object",
|
|
1183
|
+
file=sys.stderr,
|
|
1184
|
+
)
|
|
1185
|
+
return 2
|
|
1186
|
+
alerts_block = dict(existing_alerts or {})
|
|
1187
|
+
alerts_block["quota"] = parsed
|
|
1188
|
+
candidate = {**config, "alerts": alerts_block}
|
|
1189
|
+
try:
|
|
1190
|
+
_get_alerts_config(candidate)
|
|
1191
|
+
normalized = _get_quota_alerts_config(candidate)
|
|
1192
|
+
except _AlertsConfigError as exc:
|
|
1193
|
+
print(f"cctally: alerts config error: {exc}", file=sys.stderr)
|
|
1194
|
+
return 2
|
|
1195
|
+
alerts_block["quota"] = normalized
|
|
1196
|
+
config["alerts"] = alerts_block
|
|
1197
|
+
save_config(config)
|
|
1198
|
+
if getattr(args, "emit_json", False):
|
|
1199
|
+
print(json.dumps({"alerts": {"quota": normalized}}, indent=2))
|
|
1200
|
+
else:
|
|
1201
|
+
print(f"alerts.quota={json.dumps(normalized)}")
|
|
1202
|
+
return 0
|
|
881
1203
|
if key == "dashboard.bind":
|
|
882
1204
|
# Validation rejects whitespace / empty / non-string up front;
|
|
883
1205
|
# write proceeds under config_writer_lock with _load_config_unlocked
|
|
@@ -1350,6 +1672,49 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1350
1672
|
if key not in ALLOWED_CONFIG_KEYS:
|
|
1351
1673
|
eprint(f"cctally config: unknown config key {key!r}")
|
|
1352
1674
|
return 2
|
|
1675
|
+
if key.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
1676
|
+
c = _cctally()
|
|
1677
|
+
configured: dict | None = None
|
|
1678
|
+
try:
|
|
1679
|
+
with config_writer_lock():
|
|
1680
|
+
config = _load_config_unlocked()
|
|
1681
|
+
budget = config.get("budget")
|
|
1682
|
+
if budget is not None and not isinstance(budget, dict):
|
|
1683
|
+
raise _BudgetConfigError("budget must be an object")
|
|
1684
|
+
existing = (budget or {}).get("codex")
|
|
1685
|
+
if existing is None:
|
|
1686
|
+
return 0
|
|
1687
|
+
if not isinstance(existing, dict):
|
|
1688
|
+
raise _BudgetConfigError(
|
|
1689
|
+
f"budget.codex must be an object or null, got {type(existing).__name__}"
|
|
1690
|
+
)
|
|
1691
|
+
validated_existing = _validate_codex_budget_block(existing)
|
|
1692
|
+
assert validated_existing is not None
|
|
1693
|
+
leaf = key.removeprefix(_CODEX_BUDGET_LEAF_PREFIX)
|
|
1694
|
+
block = dict(budget or {})
|
|
1695
|
+
if leaf == "amount_usd":
|
|
1696
|
+
block.pop("codex", None)
|
|
1697
|
+
if block:
|
|
1698
|
+
config["budget"] = block
|
|
1699
|
+
else:
|
|
1700
|
+
config.pop("budget", None)
|
|
1701
|
+
save_config(config)
|
|
1702
|
+
return 0
|
|
1703
|
+
prospective = dict(validated_existing)
|
|
1704
|
+
prospective.pop(leaf, None)
|
|
1705
|
+
configured = _validate_codex_budget_block(prospective)
|
|
1706
|
+
assert configured is not None
|
|
1707
|
+
block["codex"] = configured
|
|
1708
|
+
config["budget"] = block
|
|
1709
|
+
save_config(config)
|
|
1710
|
+
except _BudgetConfigError as exc:
|
|
1711
|
+
eprint(f"cctally config: {exc}")
|
|
1712
|
+
return 2
|
|
1713
|
+
# An optional leaf leaves a configured block, even when it already
|
|
1714
|
+
# held its default; the forward-only state must be reconciled once.
|
|
1715
|
+
assert configured is not None
|
|
1716
|
+
c._reconcile_codex_budget_on_config_write({"codex": configured})
|
|
1717
|
+
return 0
|
|
1353
1718
|
if key == "display.tz":
|
|
1354
1719
|
with config_writer_lock():
|
|
1355
1720
|
config = _load_config_unlocked()
|
|
@@ -1366,6 +1731,7 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1366
1731
|
"alerts.projected_enabled",
|
|
1367
1732
|
"alerts.notifier",
|
|
1368
1733
|
"alerts.command_template",
|
|
1734
|
+
"alerts.quota",
|
|
1369
1735
|
):
|
|
1370
1736
|
# Mirror the display.tz branch: writer-lock + _load_config_unlocked
|
|
1371
1737
|
# (NOT load_config — fcntl.flock is per-fd so re-entry would
|