cctally 1.21.2 → 1.21.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.21.3] - 2026-05-29
9
+
10
+ ### Fixed
11
+ - `cctally doctor` no longer warns about a "legacy status-line snippet" when your `~/.claude/statusline-command.sh` merely *mentions* `cctally record-usage` inside a shell comment — e.g. a trailing `# NOTE:` documenting that the old background invocation was removed. The detector (`_setup_detect_legacy_snippet`) tested only `"cctally record-usage" in line` with no comment-stripping, so a `#`-prefixed prose reference was reported identically to a line that actually executes it, and the WARN surfaced in terminal `doctor` output, the dashboard freshness chip + doctor modal, and the TUI hero card — nudging users to delete a harmless comment of their own. It now skips any line whose first non-whitespace character is `#` (POSIX shell only treats `#` as a comment marker at start-of-token, so natural-language comments are excluded without trying to parse the shell); actually-executing legacy lines like `exec cctally record-usage "$@"` still fire exactly as before. Regression: new `tests/test_setup_legacy_snippet.py` unit coverage (comment-only / indented-comment / real-line / mixed cases) plus a new `bin/cctally-doctor-test` scenario `14-legacy-snippet-in-comment-ignored` that asserts a clean install carrying the needle only in comments stays fully OK. (#115)
12
+ - Homebrew installs no longer create dangling `~/.local/bin/` symlinks, and Claude Code hooks now point at the version-stable `<prefix>/bin/cctally` so they survive `brew cleanup`; `cctally setup` from a brew install relies on the formula's `<prefix>/bin/` and cleans up legacy keg/npm leftover links. (#119)
13
+ - `cctally doctor` `install.path` is now availability-aware (passes whenever cctally is genuinely reachable on `$PATH` via any channel — Homebrew `<prefix>/bin/`, an npm prefix, or source `~/.local/bin` — rather than merely because `~/.local/bin` is on `$PATH`), and when it does warn the remediation is tailored to how cctally was installed (Homebrew → `eval "$(brew shellenv)"`; source/npm → `export PATH="$HOME/.local/bin:$PATH"` + `cctally setup`); `install.symlinks` reports leftover keg links as a cleanable "stale" state instead of a generic failure. (#119)
14
+
8
15
  ## [1.21.2] - 2026-05-28
9
16
 
10
17
  ### Fixed
@@ -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),
@@ -103,6 +103,33 @@ class DoctorState:
103
103
  dev_mode: bool
104
104
  app_dir: str
105
105
  is_dev_checkout: bool = False
106
+ # Issue #119: availability-aware install checks. Both precomputed by
107
+ # `doctor_gather_state` (the I/O layer) so the kernel stays pure —
108
+ # `shutil.which` and the on-disk legacy-link probe never run here.
109
+ # Defaulted (and placed last, after `is_dev_checkout`) so existing
110
+ # constructors that don't pass them still work and the dataclass's
111
+ # non-default-then-default field ordering stays valid.
112
+ # * cctally_reachable_on_path — `shutil.which("cctally") is not None`;
113
+ # channel-agnostic (brew `<prefix>/bin`, npm prefix, source
114
+ # `~/.local/bin` all satisfy it). Lets `_check_install_path` pass
115
+ # whenever the command is reachable, not only when `~/.local/bin`
116
+ # is on PATH.
117
+ # * symlinks_path_pinned — true iff cctally is reachable ONLY through
118
+ # a legacy `~/.local/bin` link to a retired/foreign (e.g. Homebrew
119
+ # keg) install (a live retired link with no `reachable_elsewhere`
120
+ # fallback). The kernel can't tell this `wrong`-mode apart from an
121
+ # ordinary occupied slot from `(name, state)` alone, so it's
122
+ # precomputed; drives the PATH-fix remediation in
123
+ # `_check_install_symlinks`.
124
+ # * install_is_brew — true iff this cctally runs from a Homebrew keg
125
+ # (`_setup_is_brew_install(repo_root)`). Channel knowledge the
126
+ # kernel can't derive from `repo_root` (it does no I/O); drives the
127
+ # channel-aware `_check_install_path` WARN remediation so a brew
128
+ # install isn't told to fix a `~/.local/bin` it deliberately
129
+ # doesn't use (#119 made brew `~/.local/bin`-free).
130
+ cctally_reachable_on_path: Optional[bool] = None
131
+ symlinks_path_pinned: bool = False
132
+ install_is_brew: bool = False
106
133
 
107
134
 
108
135
  @dataclasses.dataclass(frozen=True)
@@ -145,6 +172,11 @@ def _max_severity(severities: list[str]) -> str:
145
172
 
146
173
 
147
174
  def _check_install_symlinks(s: DoctorState) -> CheckResult:
175
+ # Issue #119: the symlink state grew a fourth value, `stale` — a
176
+ # retired/foreign (e.g. Homebrew keg) link whose command IS still
177
+ # reachable elsewhere, so the link is safely-cleanable cruft, not a
178
+ # broken slot. Count `available = ok + stale` (both ⟹ reachable);
179
+ # `bad = wrong + missing` is what is genuinely actionable.
148
180
  if s.symlink_state is None:
149
181
  return CheckResult(
150
182
  id="install.symlinks", title="Symlinks",
@@ -152,35 +184,89 @@ def _check_install_symlinks(s: DoctorState) -> CheckResult:
152
184
  remediation="See logs", details={"reason": "gather returned None"},
153
185
  )
154
186
  total = len(s.symlink_state)
155
- missing = [n for n, st in s.symlink_state if st != "ok"]
156
- ok_count = total - len(missing)
157
- if not missing:
187
+ stale = [n for n, st in s.symlink_state if st == "stale"]
188
+ bad = [n for n, st in s.symlink_state if st in ("wrong", "missing")]
189
+ available = total - len(bad) # available = ok + stale
190
+ # "missing" carries the full `bad` list (wrong + missing); the key name is
191
+ # kept for JSON-schema stability even though it now spans both states.
192
+ details = {"present": available, "total": total,
193
+ "missing": bad, "stale": stale}
194
+ if not bad and not stale:
158
195
  return CheckResult(
159
196
  id="install.symlinks", title="Symlinks",
160
- severity="ok", summary=f"{ok_count}/{total} available",
161
- remediation=None,
162
- details={"present": ok_count, "total": total, "missing": []},
197
+ severity="ok", summary=f"{available}/{total} available",
198
+ remediation=None, details=details,
199
+ )
200
+ if not bad: # stale only
201
+ return CheckResult(
202
+ id="install.symlinks", title="Symlinks",
203
+ severity="warn",
204
+ summary=f"{available}/{total} available; {len(stale)} stale link(s) to clean",
205
+ remediation="Run `cctally setup` to clean stale links",
206
+ details=details,
163
207
  )
208
+ # bad present
209
+ if s.symlinks_path_pinned:
210
+ # Pinned-only-path (finding #2/#10): cctally runs ONLY through a
211
+ # legacy ~/.local/bin link to a keg, so its slot classes `wrong`
212
+ # but the command works. `cctally setup` deliberately won't remove
213
+ # the only reachable copy — the actionable fix is a PATH change.
214
+ # Keep this message in sync with the pinned guidance in _setup_install
215
+ # (bin/_cctally_setup.py).
216
+ remediation = (
217
+ "cctally is reachable only through a legacy ~/.local/bin link to a "
218
+ "Homebrew keg. Put <prefix>/bin on your PATH (e.g. `eval \"$(brew shellenv)\"`), "
219
+ "then run `cctally setup` to remove the legacy link."
220
+ )
221
+ else:
222
+ remediation = "Run `cctally setup`"
223
+ summary = f"{available}/{total} available; missing/broken {', '.join(bad)}"
224
+ if stale:
225
+ summary += f"; {len(stale)} stale"
164
226
  return CheckResult(
165
227
  id="install.symlinks", title="Symlinks",
166
- severity="warn",
167
- summary=f"{ok_count}/{total} available; missing {', '.join(missing)}",
168
- remediation="Run `cctally setup`",
169
- details={"present": ok_count, "total": total, "missing": missing},
228
+ severity="warn", summary=summary, remediation=remediation, details=details,
170
229
  )
171
230
 
172
231
 
173
232
  def _check_install_path(s: DoctorState) -> CheckResult:
174
- if s.path_includes_local_bin:
233
+ # Issue #119: availability-aware. OK iff cctally is ACTUALLY reachable
234
+ # on $PATH via ANY channel — brew `<prefix>/bin`, npm prefix, or source
235
+ # `~/.local/bin` (`shutil.which`, precomputed in the I/O layer). Mere
236
+ # `~/.local/bin` membership is NOT sufficient: doctor can be launched by
237
+ # absolute path or from another UI with `~/.local/bin` on $PATH yet no
238
+ # `cctally` installed there (the brew-only #119 case), which must WARN.
239
+ # `path_includes_local_bin` is only a fail-soft fallback for when the
240
+ # reachability probe could not run (None), so a gather failure never
241
+ # hard-WARNs an otherwise-working install.
242
+ reachable = s.cctally_reachable_on_path
243
+ if reachable is None:
244
+ reachable = bool(s.path_includes_local_bin)
245
+ if reachable:
175
246
  return CheckResult(
176
247
  id="install.path", title="PATH",
177
- severity="ok", summary="~/.local/bin on $PATH",
248
+ severity="ok", summary="cctally reachable on $PATH",
178
249
  remediation=None, details={},
179
250
  )
251
+ # Channel-aware remediation: a Homebrew keg keeps cctally on
252
+ # `<prefix>/bin` and deliberately owns no `~/.local/bin` symlinks
253
+ # (#119), so the `~/.local/bin` / `cctally setup` hint would be wrong
254
+ # for it — point brew users at `brew shellenv` instead (matching the
255
+ # pinned-only-path remediation in `_check_install_symlinks`). Source /
256
+ # npm installs keep the `~/.local/bin` + `cctally setup` guidance.
257
+ if s.install_is_brew:
258
+ remediation = (
259
+ "Put `<prefix>/bin` on your PATH (e.g. `eval \"$(brew shellenv)\"`)"
260
+ )
261
+ else:
262
+ remediation = (
263
+ "Append `export PATH=\"$HOME/.local/bin:$PATH\"` to your shell rc, "
264
+ "or run `cctally setup`"
265
+ )
180
266
  return CheckResult(
181
267
  id="install.path", title="PATH",
182
- severity="warn", summary="~/.local/bin not on $PATH",
183
- remediation="Append `export PATH=\"$HOME/.local/bin:$PATH\"` to your shell rc",
268
+ severity="warn", summary="cctally not reachable on $PATH",
269
+ remediation=remediation,
184
270
  details={},
185
271
  )
186
272
 
package/bin/cctally CHANGED
@@ -481,6 +481,8 @@ _setup_resolve_hook_target = _cctally_setup._setup_resolve_hook_target
481
481
  _setup_create_symlinks = _cctally_setup._setup_create_symlinks
482
482
  _setup_repair_symlinks = _cctally_setup._setup_repair_symlinks
483
483
  _setup_path_includes_local_bin = _cctally_setup._setup_path_includes_local_bin
484
+ _setup_symlink_is_retired = _cctally_setup._setup_symlink_is_retired
485
+ _setup_is_brew_install = _cctally_setup._setup_is_brew_install
484
486
  _setup_shell_rc_hint = _cctally_setup._setup_shell_rc_hint
485
487
  _setup_detect_legacy_snippet = _cctally_setup._setup_detect_legacy_snippet
486
488
  _setup_detect_legacy_bespoke_hooks = _cctally_setup._setup_detect_legacy_bespoke_hooks
@@ -10406,6 +10408,39 @@ def doctor_gather_state(
10406
10408
  path_includes = _setup_path_includes_local_bin()
10407
10409
  except Exception:
10408
10410
  path_includes = None
10411
+ # Issue #119: availability-aware install checks. Precomputed here (the
10412
+ # I/O layer) so the kernel stays pure — `shutil.which` and the on-disk
10413
+ # legacy-link probe never run in _lib_doctor.
10414
+ # * cctally_reachable_on_path — channel-agnostic "is the command on
10415
+ # $PATH at all?" (brew <prefix>/bin, npm prefix, source ~/.local/bin
10416
+ # all satisfy it). Lets install.path pass without a ~/.local/bin
10417
+ # membership check.
10418
+ # * symlinks_path_pinned — true iff cctally runs ONLY through a legacy
10419
+ # ~/.local/bin link to a retired/foreign install (live retired link
10420
+ # with no reachable_elsewhere fallback). Mirrors the pinned-only-path
10421
+ # predicate in _setup_install so doctor + setup agree on the fix.
10422
+ try:
10423
+ cctally_reachable_on_path = shutil.which("cctally") is not None
10424
+ except Exception:
10425
+ cctally_reachable_on_path = None
10426
+ try:
10427
+ symlinks_path_pinned = any(
10428
+ s == "wrong"
10429
+ and (dst_dir / n).is_symlink()
10430
+ and _setup_symlink_is_retired(dst_dir / n, n, repo_root)
10431
+ and (dst_dir / n).resolve(strict=False).exists()
10432
+ for n, s in (symlink_state or [])
10433
+ )
10434
+ except Exception:
10435
+ symlinks_path_pinned = False
10436
+ # install_is_brew — channel knowledge for the install.path WARN
10437
+ # remediation. Brew kegs own no ~/.local/bin symlinks (#119), so the
10438
+ # ~/.local/bin / `cctally setup` hint is wrong for them; the kernel
10439
+ # can't derive this from repo_root (no I/O), so precompute it here.
10440
+ try:
10441
+ install_is_brew = _setup_is_brew_install(repo_root)
10442
+ except Exception:
10443
+ install_is_brew = False
10409
10444
  try:
10410
10445
  legacy_snippet = _setup_detect_legacy_snippet()
10411
10446
  except Exception:
@@ -10688,6 +10723,10 @@ def doctor_gather_state(
10688
10723
  return _lib_doctor.DoctorState(
10689
10724
  symlink_state=symlink_state,
10690
10725
  path_includes_local_bin=path_includes,
10726
+ # Issue #119: availability-aware install checks (precomputed above).
10727
+ cctally_reachable_on_path=cctally_reachable_on_path,
10728
+ symlinks_path_pinned=symlinks_path_pinned,
10729
+ install_is_brew=install_is_brew,
10691
10730
  legacy_snippet=legacy_snippet,
10692
10731
  legacy_bespoke=legacy_bespoke,
10693
10732
  claude_settings=settings,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.21.2",
3
+ "version": "1.21.3",
4
4
  "description": "Claude Code usage tracker and local dashboard for Pro/Max subscription limits - weekly cost-per-percent trend, quota forecasts, threshold alerts. ccusage-compatible.",
5
5
  "homepage": "https://github.com/omrikais/cctally",
6
6
  "repository": {