cctally 1.21.2 → 1.22.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 +24 -0
- package/bin/_cctally_alerts.py +26 -1
- package/bin/_cctally_config.py +135 -0
- package/bin/_cctally_core.py +120 -0
- package/bin/_cctally_dashboard.py +155 -23
- package/bin/_cctally_db.py +3 -0
- package/bin/_cctally_record.py +148 -0
- package/bin/_cctally_setup.py +215 -91
- package/bin/_lib_alerts_payload.py +50 -0
- package/bin/_lib_budget.py +133 -0
- package/bin/_lib_doctor.py +174 -14
- package/bin/_lib_pricing.py +32 -5
- package/bin/_lib_pricing_check.py +201 -0
- package/bin/cctally +1180 -10
- package/bin/cctally-budget +4 -0
- package/dashboard/static/assets/index-BxmaYT1y.css +1 -0
- package/dashboard/static/assets/index-CLcd-Tnm.js +18 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +4 -1
- package/dashboard/static/assets/index-BJ16SzRL.js +0 -18
- package/dashboard/static/assets/index-C1xH9GBW.css +0 -1
package/bin/_cctally_setup.py
CHANGED
|
@@ -293,6 +293,12 @@ _SETUP_STALE_SYMLINK_NAMES = (
|
|
|
293
293
|
)
|
|
294
294
|
|
|
295
295
|
|
|
296
|
+
# The npm package ships a Node shim as the `cctally` entry point. Its
|
|
297
|
+
# basename differs from the command name, so any code matching a
|
|
298
|
+
# symlink's readlink target by basename must special-case it.
|
|
299
|
+
_CCTALLY_NPM_SHIM_BASENAME = "cctally-npm-shim.js"
|
|
300
|
+
|
|
301
|
+
|
|
296
302
|
def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
|
|
297
303
|
"""Resolve the symlink target for a given PATH-name.
|
|
298
304
|
|
|
@@ -310,27 +316,27 @@ def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib
|
|
|
310
316
|
documented. All other names map directly to ``bin/<name>``.
|
|
311
317
|
"""
|
|
312
318
|
if name == "cctally" and "node_modules" in repo_root.parts:
|
|
313
|
-
shim = repo_root / "bin" /
|
|
319
|
+
shim = repo_root / "bin" / _CCTALLY_NPM_SHIM_BASENAME
|
|
314
320
|
if shim.exists():
|
|
315
321
|
return shim
|
|
316
322
|
return repo_root / "bin" / name
|
|
317
323
|
|
|
318
324
|
|
|
319
325
|
def _setup_resolve_hook_target(repo_root: pathlib.Path) -> pathlib.Path:
|
|
320
|
-
"""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
but every hook fire bypasses it via ``/usr/bin/env python3`` and
|
|
329
|
-
silently fails when the user's ``python3`` doesn't meet the version
|
|
330
|
-
floor. The returned path is fully resolved (symlinks followed) so
|
|
331
|
-
the recorded hook entry survives later filesystem rearrangements
|
|
332
|
-
of the source clone or the npm install root.
|
|
326
|
+
"""Absolute path recorded in Claude Code hook entries.
|
|
327
|
+
|
|
328
|
+
Brew installs: return the version-stable `<prefix>/bin/cctally` (the
|
|
329
|
+
formula's symlink, which self-heals on `brew upgrade`) WITHOUT
|
|
330
|
+
`.resolve()` — resolving would pin the hook to the versioned keg and
|
|
331
|
+
dangle after `brew cleanup` (issue #119). Source/npm: keep the
|
|
332
|
+
`.resolve()` semantics (survives clone/node_modules rearrangement and
|
|
333
|
+
the npm-shim branch in `_setup_resolve_symlink_source`).
|
|
333
334
|
"""
|
|
335
|
+
if _setup_is_brew_install(repo_root):
|
|
336
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
337
|
+
stable = pathlib.Path(prefix) / "bin" / "cctally"
|
|
338
|
+
if stable.exists():
|
|
339
|
+
return stable
|
|
334
340
|
return _setup_resolve_symlink_source(repo_root, "cctally").resolve()
|
|
335
341
|
|
|
336
342
|
|
|
@@ -446,19 +452,23 @@ def _setup_cleanup_stale_symlinks(
|
|
|
446
452
|
"""
|
|
447
453
|
results: list[_SetupSymlinkResult] = []
|
|
448
454
|
repo_root = _setup_resolve_repo_root()
|
|
449
|
-
|
|
455
|
+
retired_names = set(_SETUP_STALE_SYMLINK_NAMES)
|
|
456
|
+
for name in dict.fromkeys(_SETUP_STALE_SYMLINK_NAMES + tuple(_cctally().SETUP_SYMLINK_NAMES)):
|
|
450
457
|
dst = dst_dir / name
|
|
451
458
|
if not _setup_symlink_is_retired(dst, name, repo_root):
|
|
452
459
|
continue
|
|
460
|
+
if name in retired_names:
|
|
461
|
+
should_remove = True # retired command: unconditional
|
|
462
|
+
else: # active name: reachability-gated
|
|
463
|
+
is_dangling = not dst.resolve(strict=False).exists() if dst.is_symlink() else False
|
|
464
|
+
should_remove = is_dangling or _reachable_elsewhere(name)
|
|
465
|
+
if not should_remove:
|
|
466
|
+
continue
|
|
453
467
|
try:
|
|
454
468
|
dst.unlink()
|
|
455
|
-
results.append(
|
|
456
|
-
_SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
|
|
457
|
-
)
|
|
469
|
+
results.append(_SetupSymlinkResult(name, "removed-stale", "stale (issue #119 cleanup)"))
|
|
458
470
|
except OSError as exc:
|
|
459
|
-
results.append(
|
|
460
|
-
_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
|
|
461
|
-
)
|
|
471
|
+
results.append(_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}"))
|
|
462
472
|
return results
|
|
463
473
|
|
|
464
474
|
|
|
@@ -522,7 +532,11 @@ def _setup_symlink_is_retired(
|
|
|
522
532
|
target = os.readlink(dst)
|
|
523
533
|
except OSError:
|
|
524
534
|
return False
|
|
525
|
-
|
|
535
|
+
target_basename = pathlib.Path(target).name
|
|
536
|
+
accepted = {name}
|
|
537
|
+
if name == "cctally":
|
|
538
|
+
accepted.add(_CCTALLY_NPM_SHIM_BASENAME)
|
|
539
|
+
if target_basename not in accepted:
|
|
526
540
|
# User-managed symlink that happens to share the name; leave alone.
|
|
527
541
|
return False
|
|
528
542
|
# Resolve target relative to the symlink's parent so relative
|
|
@@ -546,9 +560,30 @@ def _setup_symlink_is_retired(
|
|
|
546
560
|
# *from* an npm/brew install doesn't classify its own siblings as
|
|
547
561
|
# foreign — that's the active-symlinks loop's job.
|
|
548
562
|
target_str = str(target_path)
|
|
563
|
+
# "Same install root" means the target lives UNDER the current
|
|
564
|
+
# ``repo_root`` tree. Use a separator-anchored prefix check rather
|
|
565
|
+
# than a bare substring of the token: a same-``repo_root`` npm install
|
|
566
|
+
# has ``repo_root == <…>/node_modules/cctally`` (no trailing slash),
|
|
567
|
+
# so the raw ``"/node_modules/cctally/" in repo_root_str`` test would
|
|
568
|
+
# spuriously miss and classify the live channel's own link as foreign
|
|
569
|
+
# (issue #119 finding #7 — the npm `cctally` shim under its own
|
|
570
|
+
# ``node_modules/cctally`` must be preserved, not retired).
|
|
549
571
|
repo_root_str = str(repo_root)
|
|
572
|
+
repo_root_prefix = repo_root_str.rstrip(os.sep) + os.sep
|
|
573
|
+
target_under_repo_root = (
|
|
574
|
+
target_str == repo_root_str or target_str.startswith(repo_root_prefix)
|
|
575
|
+
)
|
|
550
576
|
for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
|
|
551
|
-
if token
|
|
577
|
+
if token not in target_str:
|
|
578
|
+
continue
|
|
579
|
+
# Homebrew keg links are NEVER owned by ~/.local/bin under the
|
|
580
|
+
# issue-#119 policy, so retire them regardless of which keg /
|
|
581
|
+
# whether repo_root is itself a keg. Other foreign roots
|
|
582
|
+
# (node_modules) retire only when the target is NOT under the
|
|
583
|
+
# current install root.
|
|
584
|
+
if token == _SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0]: # "/Cellar/cctally/"
|
|
585
|
+
return True
|
|
586
|
+
if not target_under_repo_root:
|
|
552
587
|
return True
|
|
553
588
|
return False
|
|
554
589
|
|
|
@@ -558,6 +593,44 @@ def _setup_path_includes_local_bin() -> bool:
|
|
|
558
593
|
return local_bin in os.environ.get("PATH", "").split(os.pathsep)
|
|
559
594
|
|
|
560
595
|
|
|
596
|
+
def _setup_is_brew_install(repo_root: pathlib.Path) -> bool:
|
|
597
|
+
"""True when this cctally runs from a Homebrew keg.
|
|
598
|
+
|
|
599
|
+
`_setup_resolve_repo_root()` `.resolve()`s `__file__`, so a brew
|
|
600
|
+
install reliably carries the `/Cellar/cctally/` token (Apple Silicon,
|
|
601
|
+
Intel, Linuxbrew all funnel through it). Reuses the single token
|
|
602
|
+
source in `_SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0]`. Cheap — no
|
|
603
|
+
`npm prefix` subprocess; we only need the brew yes/no.
|
|
604
|
+
"""
|
|
605
|
+
return _SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0] in str(repo_root)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _setup_brew_prefix(repo_root: pathlib.Path) -> str:
|
|
609
|
+
"""The Homebrew `<prefix>` (e.g. `/opt/homebrew`) for a brew keg
|
|
610
|
+
`repo_root`. Splits on the single-source brew token so we never spell
|
|
611
|
+
the Cellar path a third way. Callers must guard with
|
|
612
|
+
`_setup_is_brew_install(repo_root)` first; off a keg this returns the
|
|
613
|
+
unchanged string."""
|
|
614
|
+
return str(repo_root).split(_SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0])[0]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _reachable_elsewhere(name: str) -> bool:
|
|
618
|
+
"""Would `<name>` still be found on PATH if the ~/.local/bin slot
|
|
619
|
+
didn't exist? Excludes the ~/.local/bin directory (realpath-compared)
|
|
620
|
+
so a stale link can't satisfy its own reachability check (issue #119
|
|
621
|
+
finding #6)."""
|
|
622
|
+
local_bin = _setup_local_bin_dir()
|
|
623
|
+
try:
|
|
624
|
+
local_real = os.path.realpath(local_bin)
|
|
625
|
+
except OSError:
|
|
626
|
+
local_real = str(local_bin)
|
|
627
|
+
dirs = [
|
|
628
|
+
d for d in os.environ.get("PATH", "").split(os.pathsep)
|
|
629
|
+
if d and os.path.realpath(d) != local_real
|
|
630
|
+
]
|
|
631
|
+
return shutil.which(name, path=os.pathsep.join(dirs)) is not None
|
|
632
|
+
|
|
633
|
+
|
|
561
634
|
def _setup_shell_rc_hint() -> str:
|
|
562
635
|
shell = os.environ.get("SHELL", "")
|
|
563
636
|
if "zsh" in shell:
|
|
@@ -580,7 +653,16 @@ def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
|
|
|
580
653
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
581
654
|
except OSError:
|
|
582
655
|
continue
|
|
583
|
-
|
|
656
|
+
# Skip lines whose first non-whitespace char is `#`: a shell comment
|
|
657
|
+
# that merely references the legacy command in prose (e.g. a NOTE
|
|
658
|
+
# documenting its removal) is not an executing snippet (issue #115).
|
|
659
|
+
# POSIX shell only treats `#` as a comment marker at start-of-token,
|
|
660
|
+
# so this covers natural-language comments without parsing the shell.
|
|
661
|
+
hits = [
|
|
662
|
+
i + 1
|
|
663
|
+
for i, ln in enumerate(text.splitlines())
|
|
664
|
+
if c.LEGACY_STATUSLINE_NEEDLE in ln and not ln.lstrip().startswith("#")
|
|
665
|
+
]
|
|
584
666
|
if hits:
|
|
585
667
|
return (path, hits)
|
|
586
668
|
return None
|
|
@@ -1008,35 +1090,25 @@ def _setup_compute_symlink_state(
|
|
|
1008
1090
|
) -> "list[tuple[str, str]]":
|
|
1009
1091
|
"""Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
|
|
1010
1092
|
|
|
1011
|
-
state ∈ {"ok", "wrong", "missing"}
|
|
1012
|
-
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
(replace-vs-already).
|
|
1022
|
-
- "wrong": a non-symlink file occupies the slot, or the symlink
|
|
1023
|
-
target is dangling. Unchanged by the PATH-aware fallback below —
|
|
1024
|
-
a dangling/occupied slot stays "wrong" even if the command is
|
|
1025
|
-
reachable elsewhere (that's the §9 brew-cleanup follow-up's job).
|
|
1026
|
-
- "missing": nothing at ``dst_dir/name`` AND the command is not
|
|
1027
|
-
reachable on ``$PATH`` via another channel. When the slot is
|
|
1028
|
-
empty but ``shutil.which(name)`` resolves (e.g. a brew
|
|
1029
|
-
``<prefix>/bin`` install), the state is "ok" instead — the
|
|
1030
|
-
diagnostic question is "is cctally-X invokable?" (issue #114).
|
|
1031
|
-
|
|
1032
|
-
``repo_root`` is unused here — retained on the signature for
|
|
1033
|
-
call-site stability across `_setup_status` and `doctor_gather_state`.
|
|
1093
|
+
state ∈ {"ok", "stale", "wrong", "missing"}.
|
|
1094
|
+
- ok: resolvable non-retired link, OR empty slot reachable elsewhere.
|
|
1095
|
+
- stale: retired link (Cellar/foreign/dangling) AND command reachable
|
|
1096
|
+
via a non-~/.local/bin dir (safely cleanable). Issue #119.
|
|
1097
|
+
- wrong: non-symlink file in slot; dangling non-retired link; OR a
|
|
1098
|
+
retired link with no reachable_elsewhere fallback (broken, or
|
|
1099
|
+
the pathological only-path-live-Cellar case).
|
|
1100
|
+
- missing: empty slot, not reachable elsewhere.
|
|
1101
|
+
Retired check precedes the resolve()->ok branch so a LIVE Cellar link is
|
|
1102
|
+
classed stale, not masked as ok.
|
|
1034
1103
|
"""
|
|
1035
|
-
del repo_root # unused; see docstring
|
|
1036
1104
|
out: list[tuple[str, str]] = []
|
|
1037
1105
|
for name in _cctally().SETUP_SYMLINK_NAMES:
|
|
1038
1106
|
dst = dst_dir / name
|
|
1107
|
+
reachable = _reachable_elsewhere(name)
|
|
1039
1108
|
if dst.is_symlink():
|
|
1109
|
+
if _setup_symlink_is_retired(dst, name, repo_root):
|
|
1110
|
+
out.append((name, "stale" if reachable else "wrong"))
|
|
1111
|
+
continue
|
|
1040
1112
|
try:
|
|
1041
1113
|
dst.resolve(strict=True)
|
|
1042
1114
|
out.append((name, "ok"))
|
|
@@ -1044,11 +1116,7 @@ def _setup_compute_symlink_state(
|
|
|
1044
1116
|
out.append((name, "wrong"))
|
|
1045
1117
|
elif dst.exists():
|
|
1046
1118
|
out.append((name, "wrong"))
|
|
1047
|
-
elif
|
|
1048
|
-
# Slot empty in dst_dir but the command is reachable on PATH
|
|
1049
|
-
# via another channel (e.g. brew's <prefix>/bin). The
|
|
1050
|
-
# diagnostic question is "is cctally-X invokable?", so treat
|
|
1051
|
-
# as ok rather than false-warning. (Issue #114.)
|
|
1119
|
+
elif reachable:
|
|
1052
1120
|
out.append((name, "ok"))
|
|
1053
1121
|
else:
|
|
1054
1122
|
out.append((name, "missing"))
|
|
@@ -1085,8 +1153,11 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1085
1153
|
repo_root = _setup_resolve_repo_root()
|
|
1086
1154
|
dst_dir = _setup_local_bin_dir()
|
|
1087
1155
|
sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1088
|
-
sym_ok = sum(1 for _, s in sym_state if s
|
|
1089
|
-
|
|
1156
|
+
sym_ok = sum(1 for _, s in sym_state if s in ("ok", "stale")) # available = ok + stale
|
|
1157
|
+
active_stale = [n for n, s in sym_state if s == "stale"]
|
|
1158
|
+
retired_stale = _setup_detect_stale_symlinks(dst_dir)
|
|
1159
|
+
stale_syms = list(dict.fromkeys(active_stale + retired_stale)) # union, order-stable
|
|
1160
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1090
1161
|
on_path = _setup_path_includes_local_bin()
|
|
1091
1162
|
try:
|
|
1092
1163
|
settings = c._load_claude_settings()
|
|
@@ -1140,8 +1211,12 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1140
1211
|
f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
|
|
1141
1212
|
)
|
|
1142
1213
|
out.append(" run `cctally setup` to remove")
|
|
1143
|
-
|
|
1144
|
-
|
|
1214
|
+
if is_brew and (on_path or any(s in ("ok", "stale") for _, s in sym_state)):
|
|
1215
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1216
|
+
out.append(f" PATH brew: commands via {prefix}/bin ✓")
|
|
1217
|
+
else:
|
|
1218
|
+
out.append(f" PATH includes {'yes' if on_path else 'no'} "
|
|
1219
|
+
f"{'✓' if on_path else '⚠'}")
|
|
1145
1220
|
out.append(f"Hooks ({_cctally_core.CLAUDE_SETTINGS_PATH})")
|
|
1146
1221
|
for ev in c.SETUP_HOOK_EVENTS:
|
|
1147
1222
|
marker = "✓" if hook_counts[ev] >= 1 else "✗"
|
|
@@ -1381,14 +1456,20 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1381
1456
|
new = sum(1 for _, s in sym_results if s == "would-create")
|
|
1382
1457
|
same = sum(1 for _, s in sym_results if s == "already")
|
|
1383
1458
|
blocked = [name for name, s in sym_results if s == "blocked"]
|
|
1459
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1384
1460
|
out: list[str] = []
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
f"
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
out.append(
|
|
1391
|
-
|
|
1461
|
+
if is_brew:
|
|
1462
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1463
|
+
out.append(f"Brew install — would skip ~/.local/bin/ symlinks "
|
|
1464
|
+
f"(commands on PATH via {prefix}/bin/)")
|
|
1465
|
+
else:
|
|
1466
|
+
out.append(
|
|
1467
|
+
f"Would symlink {len(c.SETUP_SYMLINK_NAMES)} files to {dst_dir}/ "
|
|
1468
|
+
f"({same} already correct, {new} new)"
|
|
1469
|
+
)
|
|
1470
|
+
if blocked:
|
|
1471
|
+
out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
|
|
1472
|
+
out.append(" Remove them manually then re-run.")
|
|
1392
1473
|
|
|
1393
1474
|
out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
|
|
1394
1475
|
abs_path = str(_setup_resolve_hook_target(repo_root))
|
|
@@ -1460,13 +1541,18 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1460
1541
|
envelope = {
|
|
1461
1542
|
"schema_version": 1,
|
|
1462
1543
|
"mode": "dry-run",
|
|
1463
|
-
"symlinks":
|
|
1464
|
-
"would_create":
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1544
|
+
"symlinks": (
|
|
1545
|
+
{"skipped": True, "reason": "brew", "would_create": 0,
|
|
1546
|
+
"already": 0, "blocked": [], "destination": str(dst_dir),
|
|
1547
|
+
"total": 0,
|
|
1548
|
+
"would_remove_stale": [
|
|
1549
|
+
n for n, s in _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1550
|
+
if s == "stale"
|
|
1551
|
+
]}
|
|
1552
|
+
if is_brew else
|
|
1553
|
+
{"would_create": new, "already": same, "blocked": blocked,
|
|
1554
|
+
"destination": str(dst_dir), "total": len(c.SETUP_SYMLINK_NAMES)}
|
|
1555
|
+
),
|
|
1470
1556
|
"hooks": {
|
|
1471
1557
|
"would_add": [
|
|
1472
1558
|
{
|
|
@@ -1658,24 +1744,40 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1658
1744
|
# disturbs a user's hand-rolled symlink sharing the name.
|
|
1659
1745
|
stale_results = _setup_cleanup_stale_symlinks(dst_dir)
|
|
1660
1746
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1747
|
+
# Issue #119: brew owns <prefix>/bin/, never ~/.local/bin/. On a brew
|
|
1748
|
+
# install, skip symlink CREATION entirely (commands reach PATH via the
|
|
1749
|
+
# formula's <prefix>/bin/) but keep everything else — cleanup, hook
|
|
1750
|
+
# wiring, cache bootstrap. The new/same/repl counts are initialized to
|
|
1751
|
+
# 0 here so the install JSON envelope references are always bound on
|
|
1752
|
+
# the brew branch (sym_results stays empty).
|
|
1753
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1754
|
+
new_count = same_count = repl_count = 0
|
|
1755
|
+
if is_brew:
|
|
1756
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1757
|
+
out.append(
|
|
1758
|
+
f"✓ Brew install detected — commands are on PATH via {prefix}/bin/; "
|
|
1759
|
+
f"skipping ~/.local/bin/ symlinks"
|
|
1760
|
+
)
|
|
1761
|
+
sym_results = []
|
|
1762
|
+
else:
|
|
1763
|
+
sym_results = _setup_create_symlinks(repo_root, dst_dir)
|
|
1764
|
+
failed = [r for r in sym_results if r.status == "failed"]
|
|
1765
|
+
if failed:
|
|
1766
|
+
for r in failed:
|
|
1767
|
+
eprint(f"setup: symlink {r.name} failed: {r.detail}")
|
|
1768
|
+
return 1
|
|
1769
|
+
new_count = sum(1 for r in sym_results if r.status == "created")
|
|
1770
|
+
same_count = sum(1 for r in sym_results if r.status == "already")
|
|
1771
|
+
repl_count = sum(1 for r in sym_results if r.status == "replaced")
|
|
1772
|
+
detail_parts = []
|
|
1773
|
+
if new_count:
|
|
1774
|
+
detail_parts.append(f"{new_count} newly created")
|
|
1775
|
+
if same_count:
|
|
1776
|
+
detail_parts.append(f"{same_count} already correct")
|
|
1777
|
+
if repl_count:
|
|
1778
|
+
detail_parts.append(f"{repl_count} re-pointed")
|
|
1779
|
+
detail = ", ".join(detail_parts) or "no changes"
|
|
1780
|
+
out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
|
|
1679
1781
|
removed_stale = [r for r in stale_results if r.status == "removed-stale"]
|
|
1680
1782
|
failed_stale = [r for r in stale_results if r.status == "failed"]
|
|
1681
1783
|
if removed_stale:
|
|
@@ -1687,7 +1789,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1687
1789
|
out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
|
|
1688
1790
|
warnings += 1
|
|
1689
1791
|
|
|
1690
|
-
if not _setup_path_includes_local_bin():
|
|
1792
|
+
if not is_brew and not _setup_path_includes_local_bin():
|
|
1691
1793
|
warnings += 1
|
|
1692
1794
|
rc = _setup_shell_rc_hint()
|
|
1693
1795
|
out.append(f"⚠ {dst_dir} is not on your PATH. Add to {rc}:")
|
|
@@ -1695,6 +1797,25 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1695
1797
|
out.append(" Then reload (`source ...`) or open a new terminal.")
|
|
1696
1798
|
out.append(" (Hooks still work — we used absolute paths in settings.json.)")
|
|
1697
1799
|
|
|
1800
|
+
# Pinned-only-path guidance (issue #119 finding #10): if any active
|
|
1801
|
+
# name's slot is a live retired link with no reachable_elsewhere
|
|
1802
|
+
# fallback, setup deliberately won't remove it (would break the only
|
|
1803
|
+
# reachable copy) — surface the actionable PATH fix instead of silence.
|
|
1804
|
+
pinned = [
|
|
1805
|
+
n for n, s in _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1806
|
+
if s == "wrong" and (dst_dir / n).is_symlink()
|
|
1807
|
+
and _setup_symlink_is_retired(dst_dir / n, n, repo_root)
|
|
1808
|
+
and (dst_dir / n).resolve(strict=False).exists()
|
|
1809
|
+
]
|
|
1810
|
+
if pinned:
|
|
1811
|
+
prefix = _setup_brew_prefix(repo_root) if is_brew else "<prefix>"
|
|
1812
|
+
out.append(
|
|
1813
|
+
f"⚠ cctally is reachable only via a legacy ~/.local/bin link. "
|
|
1814
|
+
f"Put {prefix}/bin on your PATH (eval \"$(brew shellenv)\"), then "
|
|
1815
|
+
f"re-run cctally setup to clean it."
|
|
1816
|
+
)
|
|
1817
|
+
warnings += 1
|
|
1818
|
+
|
|
1698
1819
|
c._backup_claude_settings()
|
|
1699
1820
|
try:
|
|
1700
1821
|
c._write_claude_settings_atomic(settings)
|
|
@@ -1863,8 +1984,11 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1863
1984
|
"created": new_count,
|
|
1864
1985
|
"already": same_count,
|
|
1865
1986
|
"replaced": repl_count,
|
|
1866
|
-
"total": len(sym_results),
|
|
1987
|
+
"total": len(sym_results), # 0 on brew (sym_results == [])
|
|
1867
1988
|
"destination": str(dst_dir),
|
|
1989
|
+
**({"skipped": True, "reason": "brew",
|
|
1990
|
+
"stale_removed": [r.name for r in stale_results if r.status == "removed-stale"]}
|
|
1991
|
+
if is_brew else {}),
|
|
1868
1992
|
},
|
|
1869
1993
|
"hooks": {
|
|
1870
1994
|
"events_added": list(c.SETUP_HOOK_EVENTS),
|
|
@@ -192,3 +192,53 @@ def _build_alert_payload_five_hour(
|
|
|
192
192
|
"primary_model": primary_model,
|
|
193
193
|
},
|
|
194
194
|
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _alert_text_budget(payload: dict, tz: "ZoneInfo | None") -> tuple[str, str, str]:
|
|
198
|
+
"""Build (title, subtitle, body) for an equiv-$ budget threshold alert.
|
|
199
|
+
|
|
200
|
+
``week_start_at`` is an instant, but the budget alert text doesn't render
|
|
201
|
+
it (the subtitle is the threshold, the body the dollar progress) — so no
|
|
202
|
+
``format_display_dt`` call is needed here. ``tz`` is accepted for
|
|
203
|
+
signature parity with peer ``_alert_text_*`` builders and intentionally
|
|
204
|
+
unused.
|
|
205
|
+
"""
|
|
206
|
+
threshold = int(payload["threshold"])
|
|
207
|
+
title = "cctally - budget"
|
|
208
|
+
subtitle = f"{threshold}% of budget"
|
|
209
|
+
ctx = payload.get("context") or {}
|
|
210
|
+
spent = float(ctx.get("spent_usd") or 0.0)
|
|
211
|
+
budget = float(ctx.get("budget_usd") or 0.0)
|
|
212
|
+
consumption = float(ctx.get("consumption_pct") or 0.0)
|
|
213
|
+
body = f"${spent:,.2f} of ${budget:,.2f} ({consumption:.0f}% of budget)"
|
|
214
|
+
return title, subtitle, body
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _build_alert_payload_budget(
|
|
218
|
+
*,
|
|
219
|
+
threshold: int,
|
|
220
|
+
crossed_at_utc: str,
|
|
221
|
+
week_start_at: str,
|
|
222
|
+
budget_usd: float,
|
|
223
|
+
spent_usd: float,
|
|
224
|
+
consumption_pct: float,
|
|
225
|
+
) -> dict:
|
|
226
|
+
"""Build the alert payload for an equiv-$ budget threshold crossing.
|
|
227
|
+
|
|
228
|
+
See ``_build_alert_payload_weekly`` for the ``alerted_at == crossed_at``
|
|
229
|
+
rationale (set-then-dispatch invariant). ``axis: "budget"`` is the third
|
|
230
|
+
alert axis (Task 4 surfaces it in the dashboard Recent-alerts panel).
|
|
231
|
+
"""
|
|
232
|
+
return {
|
|
233
|
+
"id": f"budget:{week_start_at}:{threshold}",
|
|
234
|
+
"axis": "budget",
|
|
235
|
+
"threshold": int(threshold),
|
|
236
|
+
"crossed_at": crossed_at_utc,
|
|
237
|
+
"alerted_at": crossed_at_utc, # set-then-dispatch
|
|
238
|
+
"context": {
|
|
239
|
+
"week_start_at": week_start_at,
|
|
240
|
+
"budget_usd": float(budget_usd),
|
|
241
|
+
"spent_usd": float(spent_usd),
|
|
242
|
+
"consumption_pct": float(consumption_pct),
|
|
243
|
+
},
|
|
244
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Pure-function kernel for `cctally budget` (no I/O — every dep injected).
|
|
2
|
+
|
|
3
|
+
Mirrors the _lib_statusline.py / _lib_doctor.py / _lib_pricing_check.py
|
|
4
|
+
pattern. Re-exported on the cctally module. See
|
|
5
|
+
docs/superpowers/specs/2026-05-29-cctally-budget-design.md §3.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import datetime as dt
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
# Early in the week / no data → projections are unreliable; annotate LOW CONF
|
|
13
|
+
# (mirrors forecast's thin-data caution). Tunable single source of truth.
|
|
14
|
+
_BUDGET_LOW_CONF_ELAPSED_FRACTION = 0.15
|
|
15
|
+
# Fallback warn fraction when alert_thresholds is empty (alerts silenced) but
|
|
16
|
+
# we still render a verdict.
|
|
17
|
+
_BUDGET_DEFAULT_WARN_FRACTION = 0.90
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def project_linear(
|
|
21
|
+
current: float,
|
|
22
|
+
remaining: float,
|
|
23
|
+
rate_low: float,
|
|
24
|
+
rate_high: float,
|
|
25
|
+
) -> tuple[float, float]:
|
|
26
|
+
"""Project ``current + rate * remaining`` for a (low, high) rate band.
|
|
27
|
+
|
|
28
|
+
Pure; unit-agnostic — percent for forecast, dollars for budget. The caller
|
|
29
|
+
is responsible for passing ``rate_low <= rate_high`` if it wants ordered
|
|
30
|
+
output; this primitive does NOT sort (forecast sorts the outputs to stay a
|
|
31
|
+
byte-exact no-op vs its goldens; budget passes a pre-ordered band).
|
|
32
|
+
"""
|
|
33
|
+
return (current + rate_low * remaining, current + rate_high * remaining)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class BudgetInputs:
|
|
38
|
+
target_usd: float
|
|
39
|
+
spent_usd: float # cumulative equiv-$ this subscription week
|
|
40
|
+
recent_24h_usd: float # trailing-24h equiv-$ (recent-rate projection)
|
|
41
|
+
week_start_at: dt.datetime # effective (post-reset) week start, tz-aware
|
|
42
|
+
week_end_at: dt.datetime # tz-aware
|
|
43
|
+
now: dt.datetime # tz-aware
|
|
44
|
+
alert_thresholds: tuple[int, ...]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class BudgetStatus:
|
|
49
|
+
spent_usd: float
|
|
50
|
+
remaining_usd: float # target - spent (may be < 0)
|
|
51
|
+
consumption_pct: float # spent / target * 100 (monotonic key)
|
|
52
|
+
elapsed_fraction: float # [0, 1]
|
|
53
|
+
projected_eow_low_usd: float
|
|
54
|
+
projected_eow_high_usd: float
|
|
55
|
+
verdict: str # "ok" | "warn" | "over"
|
|
56
|
+
daily_budget_remaining_usd: float # remaining / remaining-days
|
|
57
|
+
daily_pace_usd: float # current burn $/day (week-average)
|
|
58
|
+
low_confidence: bool
|
|
59
|
+
crossed_thresholds: tuple[int, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def compute_budget_status(inputs: BudgetInputs) -> BudgetStatus:
|
|
63
|
+
"""Compute budget status from injected inputs. Pure; deterministic."""
|
|
64
|
+
target = float(inputs.target_usd)
|
|
65
|
+
spent = float(inputs.spent_usd)
|
|
66
|
+
|
|
67
|
+
total_seconds = (inputs.week_end_at - inputs.week_start_at).total_seconds()
|
|
68
|
+
elapsed_seconds = (inputs.now - inputs.week_start_at).total_seconds()
|
|
69
|
+
# Clamp elapsed into [0, total] so a now before/after the window stays sane.
|
|
70
|
+
if total_seconds <= 0:
|
|
71
|
+
elapsed_seconds = 0.0
|
|
72
|
+
elapsed_fraction = 0.0
|
|
73
|
+
else:
|
|
74
|
+
elapsed_seconds = max(0.0, min(elapsed_seconds, total_seconds))
|
|
75
|
+
elapsed_fraction = elapsed_seconds / total_seconds
|
|
76
|
+
remaining_seconds = max(0.0, total_seconds - elapsed_seconds)
|
|
77
|
+
|
|
78
|
+
elapsed_hours = elapsed_seconds / 3600.0
|
|
79
|
+
remaining_hours = remaining_seconds / 3600.0
|
|
80
|
+
remaining_days = remaining_hours / 24.0
|
|
81
|
+
|
|
82
|
+
consumption_pct = (spent / target * 100.0) if target > 0 else 0.0
|
|
83
|
+
remaining_usd = target - spent
|
|
84
|
+
|
|
85
|
+
# Dollar rates ($/hour). Week-average from spend-so-far; recent from
|
|
86
|
+
# trailing-24h spend. Ordered band low<=high for project_linear.
|
|
87
|
+
rate_avg = (spent / elapsed_hours) if elapsed_hours > 0 else 0.0
|
|
88
|
+
rate_recent = float(inputs.recent_24h_usd) / 24.0
|
|
89
|
+
rate_low = min(rate_avg, rate_recent)
|
|
90
|
+
rate_high = max(rate_avg, rate_recent)
|
|
91
|
+
|
|
92
|
+
projected_low, projected_high = project_linear(
|
|
93
|
+
spent, remaining_hours, rate_low, rate_high
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
daily_pace_usd = rate_avg * 24.0
|
|
97
|
+
daily_budget_remaining_usd = (
|
|
98
|
+
(remaining_usd / remaining_days) if remaining_days > 0 else remaining_usd
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
thresholds = tuple(sorted(set(int(t) for t in inputs.alert_thresholds)))
|
|
102
|
+
if thresholds:
|
|
103
|
+
warn_fraction = min(thresholds) / 100.0
|
|
104
|
+
else:
|
|
105
|
+
warn_fraction = _BUDGET_DEFAULT_WARN_FRACTION
|
|
106
|
+
|
|
107
|
+
projected = max(projected_low, projected_high)
|
|
108
|
+
if spent > target or projected > target:
|
|
109
|
+
verdict = "over"
|
|
110
|
+
elif projected >= warn_fraction * target:
|
|
111
|
+
verdict = "warn"
|
|
112
|
+
else:
|
|
113
|
+
verdict = "ok"
|
|
114
|
+
|
|
115
|
+
low_confidence = (
|
|
116
|
+
elapsed_fraction < _BUDGET_LOW_CONF_ELAPSED_FRACTION or spent <= 0.0
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
crossed = tuple(t for t in thresholds if consumption_pct + 1e-9 >= t)
|
|
120
|
+
|
|
121
|
+
return BudgetStatus(
|
|
122
|
+
spent_usd=spent,
|
|
123
|
+
remaining_usd=remaining_usd,
|
|
124
|
+
consumption_pct=consumption_pct,
|
|
125
|
+
elapsed_fraction=elapsed_fraction,
|
|
126
|
+
projected_eow_low_usd=projected_low,
|
|
127
|
+
projected_eow_high_usd=projected_high,
|
|
128
|
+
verdict=verdict,
|
|
129
|
+
daily_budget_remaining_usd=daily_budget_remaining_usd,
|
|
130
|
+
daily_pace_usd=daily_pace_usd,
|
|
131
|
+
low_confidence=low_confidence,
|
|
132
|
+
crossed_thresholds=crossed,
|
|
133
|
+
)
|