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.
@@ -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" / "cctally-npm-shim.js"
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
- """Resolve the absolute path that goes into Claude Code hook entries.
321
-
322
- Single chokepoint shared by ``_setup_install``, ``_setup_dry_run``
323
- (text + JSON envelopes), and any other site that emits a hook
324
- command. Routes through :func:`_setup_resolve_symlink_source` so
325
- npm-layout installs point hooks at the Node shim instead of the
326
- Python script directly without this, the shim's
327
- ``CCTALLY_PYTHON`` honoring works for interactive ``cctally`` calls
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
- for name in _SETUP_STALE_SYMLINK_NAMES:
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
- if pathlib.Path(target).name != name:
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 in target_str and token not in repo_root_str:
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
- hits = [i + 1 for i, ln in enumerate(text.splitlines()) if c.LEGACY_STATUSLINE_NEEDLE in ln]
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
- - "ok": ``dst_dir/name`` is a symlink whose target is reachable.
1013
- The target is NOT required to match ``repo_root/bin/<name>`` —
1014
- power users routinely have the symlinks installed by one
1015
- cctally channel (npm/brew) while running ``doctor`` from a
1016
- parallel source clone, which would otherwise produce a 0/N
1017
- false negative on a perfectly healthy install. The diagnostic
1018
- question is "is `cctally-X` invokable from PATH?", not "did
1019
- THIS checkout install the symlink?". ``_setup_create_symlinks``
1020
- keeps its own strict equality check for install-management
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 shutil.which(name):
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 == "ok")
1089
- stale_syms = _setup_detect_stale_symlinks(dst_dir)
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
- out.append(f" PATH includes {'yes' if on_path else 'no'} "
1144
- f"{'✓' if on_path else '⚠'}")
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
- out.append(
1386
- f"Would symlink {len(c.SETUP_SYMLINK_NAMES)} files to {dst_dir}/ "
1387
- f"({same} already correct, {new} new)"
1388
- )
1389
- if blocked:
1390
- out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1391
- out.append(" Remove them manually then re-run.")
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": new,
1465
- "already": same,
1466
- "blocked": blocked,
1467
- "destination": str(dst_dir),
1468
- "total": len(c.SETUP_SYMLINK_NAMES),
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
- sym_results = _setup_create_symlinks(repo_root, dst_dir)
1662
- failed = [r for r in sym_results if r.status == "failed"]
1663
- if failed:
1664
- for r in failed:
1665
- eprint(f"setup: symlink {r.name} failed: {r.detail}")
1666
- return 1
1667
- new_count = sum(1 for r in sym_results if r.status == "created")
1668
- same_count = sum(1 for r in sym_results if r.status == "already")
1669
- repl_count = sum(1 for r in sym_results if r.status == "replaced")
1670
- detail_parts = []
1671
- if new_count:
1672
- detail_parts.append(f"{new_count} newly created")
1673
- if same_count:
1674
- detail_parts.append(f"{same_count} already correct")
1675
- if repl_count:
1676
- detail_parts.append(f"{repl_count} re-pointed")
1677
- detail = ", ".join(detail_parts) or "no changes"
1678
- out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
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
+ )