cctally 1.95.5 → 1.96.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.
@@ -227,6 +227,16 @@ def _init_paths_from_env() -> None:
227
227
  CLAUDE_PROJECTS_DIR = home / ".claude" / "projects"
228
228
 
229
229
 
230
+ # The statusline OAuth cache is HOST-GLOBAL and shared with the real Claude
231
+ # Code statusline, so /tmp stays the production location -- relocating it would
232
+ # change behaviour for real users. It lives here, as a module constant rather
233
+ # than inside _init_paths_from_env(), because it is deliberately NOT derived
234
+ # from APP_DIR or HOME; putting it here is what lets redirect_paths pin it and
235
+ # lets _bust_statusline_cache resolve it at CALL time instead of binding it as
236
+ # a default argument at import (#529 S4, spec section 5.4).
237
+ STATUSLINE_OAUTH_CACHE_PATH = "/tmp/claude-statusline-usage-cache.json"
238
+
239
+
230
240
  def _truthy_env(name: str) -> bool:
231
241
  """A ``1``/``true``/``yes``/any-other-non-empty env value is truthy;
232
242
  unset, empty, ``0``, ``false``, ``no`` are falsey (case-insensitive,
@@ -882,7 +892,20 @@ def note_stats_maintenance_released() -> None:
882
892
 
883
893
 
884
894
  class _AlertsConfigError(ValueError):
885
- """Raised by _get_alerts_config on invalid alerts block."""
895
+ """Raised by _get_alerts_config on invalid alerts block.
896
+
897
+ ``field`` carries the offending dotted config path (e.g.
898
+ ``"alerts.notifier"``) so ``POST /api/settings`` can answer
899
+ ``{error, field}`` on every rejection. It is set explicitly at each
900
+ raise site and never inferred from the message text, because inferring
901
+ it would let a reworded message silently move a machine-readable
902
+ pointer. Keyword-only with a ``None`` default, so every existing CLI
903
+ caller keeps working unchanged.
904
+ """
905
+
906
+ def __init__(self, message: str, *, field: "str | None" = None) -> None:
907
+ super().__init__(message)
908
+ self.field = field
886
909
 
887
910
 
888
911
  _ALERTS_CONFIG_VALID_KEYS = {
@@ -907,11 +930,15 @@ def _validate_threshold_list(name: str, value: object) -> "list[int]":
907
930
  Error messages mention `alerts.<name>` so users can locate the
908
931
  offending key in their config.json.
909
932
  """
933
+ field = f"alerts.{name}"
910
934
  if not isinstance(value, list):
911
- raise _AlertsConfigError(f"alerts.{name} must be a list of integers")
935
+ raise _AlertsConfigError(
936
+ f"alerts.{name} must be a list of integers", field=field
937
+ )
912
938
  if len(value) == 0:
913
939
  raise _AlertsConfigError(
914
- f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)"
940
+ f"alerts.{name} must not be empty (disable alerts via alerts.enabled=false)",
941
+ field=field,
915
942
  )
916
943
  out: "list[int]" = []
917
944
  prev = -1
@@ -919,19 +946,23 @@ def _validate_threshold_list(name: str, value: object) -> "list[int]":
919
946
  for item in value:
920
947
  if not isinstance(item, int) or isinstance(item, bool):
921
948
  raise _AlertsConfigError(
922
- f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}"
949
+ f"alerts.{name} items must be integers, got {type(item).__name__}: {item!r}",
950
+ field=field,
923
951
  )
924
952
  if item < 1 or item > 100:
925
953
  raise _AlertsConfigError(
926
- f"alerts.{name} items must be in [1, 100], got {item}"
954
+ f"alerts.{name} items must be in [1, 100], got {item}",
955
+ field=field,
927
956
  )
928
957
  if item in seen:
929
958
  raise _AlertsConfigError(
930
- f"alerts.{name} contains duplicate value {item}"
959
+ f"alerts.{name} contains duplicate value {item}",
960
+ field=field,
931
961
  )
932
962
  if item <= prev:
933
963
  raise _AlertsConfigError(
934
- f"alerts.{name} must be strictly increasing, got {prev} then {item}"
964
+ f"alerts.{name} must be strictly increasing, got {prev} then {item}",
965
+ field=field,
935
966
  )
936
967
  seen.add(item)
937
968
  prev = item
@@ -949,7 +980,7 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
949
980
  """
950
981
  block = (cfg or {}).get("alerts", {}) or {}
951
982
  if not isinstance(block, dict):
952
- raise _AlertsConfigError("alerts must be an object")
983
+ raise _AlertsConfigError("alerts must be an object", field="alerts")
953
984
  # warn-and-ignore unknown keys (forward compat; matches display.tz posture)
954
985
  for k in block.keys():
955
986
  if k not in _ALERTS_CONFIG_VALID_KEYS:
@@ -960,7 +991,8 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
960
991
  enabled = block.get("enabled", False)
961
992
  if not isinstance(enabled, bool):
962
993
  raise _AlertsConfigError(
963
- f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}"
994
+ f"alerts.enabled must be a JSON boolean, got {type(enabled).__name__}: {enabled!r}",
995
+ field="alerts.enabled",
964
996
  )
965
997
  weekly = _validate_threshold_list(
966
998
  "weekly_thresholds", block.get("weekly_thresholds", [90, 95])
@@ -975,7 +1007,8 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
975
1007
  if not isinstance(projected_enabled, bool):
976
1008
  raise _AlertsConfigError(
977
1009
  f"alerts.projected_enabled must be a JSON boolean, got "
978
- f"{type(projected_enabled).__name__}: {projected_enabled!r}"
1010
+ f"{type(projected_enabled).__name__}: {projected_enabled!r}",
1011
+ field="alerts.projected_enabled",
979
1012
  )
980
1013
  # Dispatch-global keys (Phase B). `notifier` selects the backend;
981
1014
  # `command_template` is an argv list for the `command` backend (and may be
@@ -985,31 +1018,39 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
985
1018
  if notifier not in _ALERTS_VALID_NOTIFIERS:
986
1019
  raise _AlertsConfigError(
987
1020
  f"alerts.notifier must be one of {list(_ALERTS_VALID_NOTIFIERS)}, "
988
- f"got {notifier!r}"
1021
+ f"got {notifier!r}",
1022
+ field="alerts.notifier",
989
1023
  )
990
1024
  command_template = block.get("command_template", None)
991
1025
  if command_template is not None:
992
1026
  if not isinstance(command_template, list) or not command_template:
993
1027
  raise _AlertsConfigError(
994
- "alerts.command_template must be null or a non-empty list of strings"
1028
+ "alerts.command_template must be null or a non-empty list of strings",
1029
+ field="alerts.command_template",
995
1030
  )
996
1031
  for el in command_template:
997
1032
  if not isinstance(el, str):
998
1033
  raise _AlertsConfigError(
999
1034
  f"alerts.command_template elements must be strings, "
1000
- f"got {type(el).__name__}: {el!r}"
1035
+ f"got {type(el).__name__}: {el!r}",
1036
+ field="alerts.command_template",
1001
1037
  )
1002
1038
  if "\x00" in el:
1003
1039
  raise _AlertsConfigError(
1004
- "alerts.command_template elements must not contain a NUL byte"
1040
+ "alerts.command_template elements must not contain a NUL byte",
1041
+ field="alerts.command_template",
1005
1042
  )
1006
1043
  if not command_template[0].strip():
1007
1044
  raise _AlertsConfigError(
1008
- "alerts.command_template[0] (the program) must not be empty/whitespace"
1045
+ "alerts.command_template[0] (the program) must not be empty/whitespace",
1046
+ field="alerts.command_template",
1009
1047
  )
1010
1048
  if notifier == "command" and command_template is None:
1049
+ # Cross-field: point at the leaf the caller just set, not at the
1050
+ # absent one, so a dashboard save highlights the field it sent.
1011
1051
  raise _AlertsConfigError(
1012
- "alerts.notifier='command' requires alerts.command_template to be set"
1052
+ "alerts.notifier='command' requires alerts.command_template to be set",
1053
+ field="alerts.notifier",
1013
1054
  )
1014
1055
  return {
1015
1056
  "enabled": enabled,
@@ -1025,7 +1066,16 @@ def _get_alerts_config(cfg: "dict | None") -> dict:
1025
1066
 
1026
1067
 
1027
1068
  class _BudgetConfigError(ValueError):
1028
- """Raised by _get_budget_config on an invalid budget block."""
1069
+ """Raised by _get_budget_config on an invalid budget block.
1070
+
1071
+ ``field`` follows the same contract as ``_AlertsConfigError.field``:
1072
+ the offending dotted path (e.g. ``"budget.codex.alerts_enabled"``),
1073
+ set explicitly at the raise site, keyword-only, defaulting to ``None``.
1074
+ """
1075
+
1076
+ def __init__(self, message: str, *, field: "str | None" = None) -> None:
1077
+ super().__init__(message)
1078
+ self.field = field
1029
1079
 
1030
1080
 
1031
1081
  def _validate_positive_budget_amount(v: object, label: str) -> float:
@@ -1039,9 +1089,11 @@ def _validate_positive_budget_amount(v: object, label: str) -> float:
1039
1089
  only validates a value the caller has already decided must be a number.
1040
1090
  """
1041
1091
  if isinstance(v, bool) or not isinstance(v, (int, float)):
1042
- raise _BudgetConfigError(f"{label} must be a number")
1092
+ raise _BudgetConfigError(f"{label} must be a number", field=label)
1043
1093
  if not math.isfinite(float(v)) or float(v) <= 0:
1044
- raise _BudgetConfigError(f"{label} must be a finite number > 0")
1094
+ raise _BudgetConfigError(
1095
+ f"{label} must be a finite number > 0", field=label
1096
+ )
1045
1097
  return float(v)
1046
1098
 
1047
1099
 
@@ -1088,23 +1140,26 @@ def _validate_account_budget_map(v: object, label: str) -> "dict[str, float]":
1088
1140
  each value a non-bool finite number > 0. Returns a cleaned copy."""
1089
1141
  if not isinstance(v, dict):
1090
1142
  raise _BudgetConfigError(
1091
- f"{label} must be an object, got {type(v).__name__}"
1143
+ f"{label} must be an object, got {type(v).__name__}", field=label
1092
1144
  )
1093
1145
  cleaned: "dict[str, float]" = {}
1094
1146
  for acc_key, acc_val in v.items():
1095
1147
  if not isinstance(acc_key, str) or not acc_key:
1096
1148
  raise _BudgetConfigError(
1097
- f"{label} keys must be non-empty strings (account keys)"
1149
+ f"{label} keys must be non-empty strings (account keys)",
1150
+ field=label,
1098
1151
  )
1099
1152
  if isinstance(acc_val, bool) or not isinstance(acc_val, (int, float)):
1100
1153
  raise _BudgetConfigError(
1101
1154
  f"{label} values must be numbers, "
1102
- f"got {type(acc_val).__name__} for key {acc_key!r}"
1155
+ f"got {type(acc_val).__name__} for key {acc_key!r}",
1156
+ field=label,
1103
1157
  )
1104
1158
  if not math.isfinite(float(acc_val)) or float(acc_val) <= 0:
1105
1159
  raise _BudgetConfigError(
1106
1160
  f"{label} values must be finite numbers > 0, "
1107
- f"got {acc_val!r} for key {acc_key!r}"
1161
+ f"got {acc_val!r} for key {acc_key!r}",
1162
+ field=label,
1108
1163
  )
1109
1164
  cleaned[acc_key] = float(acc_val)
1110
1165
  return cleaned
@@ -1125,7 +1180,8 @@ def _get_budget_config(cfg: dict) -> dict:
1125
1180
  return out
1126
1181
  if not isinstance(block, dict):
1127
1182
  raise _BudgetConfigError(
1128
- f"budget must be an object, got {type(block).__name__}"
1183
+ f"budget must be an object, got {type(block).__name__}",
1184
+ field="budget",
1129
1185
  )
1130
1186
  # warn-and-ignore unknown keys (forward compat; matches _get_alerts_config)
1131
1187
  for k in block.keys():
@@ -1140,16 +1196,25 @@ def _get_budget_config(cfg: dict) -> dict:
1140
1196
  if v is None:
1141
1197
  out["weekly_usd"] = None
1142
1198
  elif isinstance(v, bool) or not isinstance(v, (int, float)):
1143
- raise _BudgetConfigError("budget.weekly_usd must be a number or null")
1199
+ raise _BudgetConfigError(
1200
+ "budget.weekly_usd must be a number or null",
1201
+ field="budget.weekly_usd",
1202
+ )
1144
1203
  elif not math.isfinite(float(v)) or float(v) <= 0:
1145
- raise _BudgetConfigError("budget.weekly_usd must be a finite number > 0")
1204
+ raise _BudgetConfigError(
1205
+ "budget.weekly_usd must be a finite number > 0",
1206
+ field="budget.weekly_usd",
1207
+ )
1146
1208
  else:
1147
1209
  out["weekly_usd"] = float(v)
1148
1210
 
1149
1211
  if "alerts_enabled" in block:
1150
1212
  v = block["alerts_enabled"]
1151
1213
  if not isinstance(v, bool):
1152
- raise _BudgetConfigError("budget.alerts_enabled must be a boolean")
1214
+ raise _BudgetConfigError(
1215
+ "budget.alerts_enabled must be a boolean",
1216
+ field="budget.alerts_enabled",
1217
+ )
1153
1218
  out["alerts_enabled"] = v
1154
1219
 
1155
1220
  if "alert_thresholds" in block:
@@ -1162,39 +1227,47 @@ def _get_budget_config(cfg: dict) -> dict:
1162
1227
  if not isinstance(v, str) or v not in BUDGET_PERIODS:
1163
1228
  raise _BudgetConfigError(
1164
1229
  "budget.period must be one of "
1165
- f"{', '.join(BUDGET_PERIODS)}, got {v!r}"
1230
+ f"{', '.join(BUDGET_PERIODS)}, got {v!r}",
1231
+ field="budget.period",
1166
1232
  )
1167
1233
  out["period"] = v
1168
1234
 
1169
1235
  if "projected_enabled" in block:
1170
1236
  v = block["projected_enabled"]
1171
1237
  if not isinstance(v, bool):
1172
- raise _BudgetConfigError("budget.projected_enabled must be a boolean")
1238
+ raise _BudgetConfigError(
1239
+ "budget.projected_enabled must be a boolean",
1240
+ field="budget.projected_enabled",
1241
+ )
1173
1242
  out["projected_enabled"] = v
1174
1243
 
1175
1244
  if "projects" in block:
1176
1245
  v = block["projects"]
1177
1246
  if not isinstance(v, dict):
1178
1247
  raise _BudgetConfigError(
1179
- f"budget.projects must be an object, got {type(v).__name__}"
1248
+ f"budget.projects must be an object, got {type(v).__name__}",
1249
+ field="budget.projects",
1180
1250
  )
1181
1251
  cleaned: "dict[str, float]" = {}
1182
1252
  for proj_key, proj_val in v.items():
1183
1253
  if not isinstance(proj_key, str):
1184
1254
  raise _BudgetConfigError(
1185
- "budget.projects keys must be strings (canonical git-root paths)"
1255
+ "budget.projects keys must be strings (canonical git-root paths)",
1256
+ field="budget.projects",
1186
1257
  )
1187
1258
  # Reuse the weekly_usd numeric rule per value: a non-bool finite
1188
1259
  # number > 0 (bool is an int subclass, so reject it explicitly).
1189
1260
  if isinstance(proj_val, bool) or not isinstance(proj_val, (int, float)):
1190
1261
  raise _BudgetConfigError(
1191
1262
  f"budget.projects values must be numbers, "
1192
- f"got {type(proj_val).__name__} for key {proj_key!r}"
1263
+ f"got {type(proj_val).__name__} for key {proj_key!r}",
1264
+ field="budget.projects",
1193
1265
  )
1194
1266
  if not math.isfinite(float(proj_val)) or float(proj_val) <= 0:
1195
1267
  raise _BudgetConfigError(
1196
1268
  f"budget.projects values must be finite numbers > 0, "
1197
- f"got {proj_val!r} for key {proj_key!r}"
1269
+ f"got {proj_val!r} for key {proj_key!r}",
1270
+ field="budget.projects",
1198
1271
  )
1199
1272
  cleaned[proj_key] = float(proj_val)
1200
1273
  out["projects"] = cleaned
@@ -1203,7 +1276,8 @@ def _get_budget_config(cfg: dict) -> dict:
1203
1276
  v = block["project_alerts_enabled"]
1204
1277
  if not isinstance(v, bool):
1205
1278
  raise _BudgetConfigError(
1206
- "budget.project_alerts_enabled must be a boolean"
1279
+ "budget.project_alerts_enabled must be a boolean",
1280
+ field="budget.project_alerts_enabled",
1207
1281
  )
1208
1282
  out["project_alerts_enabled"] = v
1209
1283
 
@@ -1227,13 +1301,17 @@ def _validate_budget_thresholds(v: object, label: str) -> "list[int]":
1227
1301
  an empty list is allowed (alerts silenced).
1228
1302
  """
1229
1303
  if not isinstance(v, list):
1230
- raise _BudgetConfigError(f"{label} must be a list of ints")
1304
+ raise _BudgetConfigError(f"{label} must be a list of ints", field=label)
1231
1305
  cleaned: "list[int]" = []
1232
1306
  for t in v:
1233
1307
  if isinstance(t, bool) or not isinstance(t, int):
1234
- raise _BudgetConfigError(f"{label} entries must be integers")
1308
+ raise _BudgetConfigError(
1309
+ f"{label} entries must be integers", field=label
1310
+ )
1235
1311
  if t < 1 or t > 100:
1236
- raise _BudgetConfigError(f"{label} entries must be in [1, 100]")
1312
+ raise _BudgetConfigError(
1313
+ f"{label} entries must be in [1, 100]", field=label
1314
+ )
1237
1315
  cleaned.append(t)
1238
1316
  return sorted(set(cleaned)) # empty list allowed (silenced)
1239
1317
 
@@ -1252,7 +1330,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1252
1330
  return None
1253
1331
  if not isinstance(v, dict):
1254
1332
  raise _BudgetConfigError(
1255
- f"budget.codex must be an object or null, got {type(v).__name__}"
1333
+ f"budget.codex must be an object or null, got {type(v).__name__}",
1334
+ field="budget.codex",
1256
1335
  )
1257
1336
  # warn-and-ignore unknown sub-keys (forward compat, like the parent block)
1258
1337
  for k in v.keys():
@@ -1283,7 +1362,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1283
1362
  elif not _has_codex_accounts:
1284
1363
  raise _BudgetConfigError(
1285
1364
  "budget.codex.amount_usd is required (or set a per-account "
1286
- "budget.codex.accounts map)"
1365
+ "budget.codex.accounts map)",
1366
+ field="budget.codex.amount_usd",
1287
1367
  )
1288
1368
 
1289
1369
  if "period" in v:
@@ -1292,7 +1372,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1292
1372
  raise _BudgetConfigError(
1293
1373
  "budget.codex.period must be one of "
1294
1374
  f"{', '.join(CODEX_BUDGET_PERIODS)} (NOT subscription-week), "
1295
- f"got {p!r}"
1375
+ f"got {p!r}",
1376
+ field="budget.codex.period",
1296
1377
  )
1297
1378
  out["period"] = p
1298
1379
 
@@ -1300,7 +1381,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1300
1381
  ae = v["alerts_enabled"]
1301
1382
  if not isinstance(ae, bool):
1302
1383
  raise _BudgetConfigError(
1303
- "budget.codex.alerts_enabled must be a boolean"
1384
+ "budget.codex.alerts_enabled must be a boolean",
1385
+ field="budget.codex.alerts_enabled",
1304
1386
  )
1305
1387
  out["alerts_enabled"] = ae
1306
1388
 
@@ -1313,7 +1395,8 @@ def _validate_codex_budget_block(v: object) -> "dict | None":
1313
1395
  pe = v["projected_enabled"]
1314
1396
  if not isinstance(pe, bool):
1315
1397
  raise _BudgetConfigError(
1316
- "budget.codex.projected_enabled must be a boolean"
1398
+ "budget.codex.projected_enabled must be a boolean",
1399
+ field="budget.codex.projected_enabled",
1317
1400
  )
1318
1401
  out["projected_enabled"] = pe
1319
1402