cctally 1.21.1 → 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,21 @@ 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
+
15
+ ## [1.21.2] - 2026-05-28
16
+
17
+ ### Fixed
18
+ - npm upgrades now self-heal `~/.local/bin/` symlinks for newly added subcommands: the postinstall best-effort runs an internal additive `repair-symlinks` pass (gated to existing installs), so a new `cctally-*` binary is reachable immediately after `npm install -g cctally@<newer>` without re-running `cctally setup` (#114).
19
+
20
+ ### Changed
21
+ - `cctally doctor` / `cctally setup --status` now report subcommand symlinks as "available" and treat a command reachable on `$PATH` via another install channel (e.g. Homebrew) as present, instead of warning only because `~/.local/bin/` lacks the link (#114).
22
+
8
23
  ## [1.21.1] - 2026-05-28
9
24
 
10
25
  ### Fixed
@@ -1023,6 +1023,7 @@ _BANNER_SUPPRESSED_COMMANDS = frozenset({
1023
1023
  "doctor", # consolidates migration + update banner state into its
1024
1024
  # own report; double-printing the banner would duplicate
1025
1025
  # findings doctor already surfaces structurally.
1026
+ "repair-symlinks", # invoked by npm postinstall; no banner during install
1026
1027
  "blocks", # stdout-formatted table replacing `ccusage blocks`;
1027
1028
  # stderr noise pollutes the visually-aligned report and
1028
1029
  # confuses scripted pipelines piping via `2>&1`.
@@ -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
 
@@ -373,6 +379,44 @@ def _setup_create_symlinks(
373
379
  return results
374
380
 
375
381
 
382
+ @dataclasses.dataclass
383
+ class _RepairResult:
384
+ gated: bool
385
+ created: list[str]
386
+ failed: list[tuple[str, str]] # (name, detail)
387
+
388
+
389
+ def _setup_repair_symlinks(
390
+ repo_root: pathlib.Path, dst_dir: pathlib.Path,
391
+ ) -> _RepairResult:
392
+ """Additively reconcile ``dst_dir`` to ``SETUP_SYMLINK_NAMES`` (issue #114).
393
+
394
+ Existing-install gate: acts only when at least one
395
+ ``SETUP_SYMLINK_NAMES`` symlink is already present in ``dst_dir`` — a
396
+ fresh install (zero links) is left untouched so onboarding stays
397
+ opt-in. Strictly additive: creates only *genuinely empty* slots and
398
+ leaves present / wrong-target / dangling / non-symlink slots alone.
399
+ Touches nothing but ``~/.local/bin/`` symlinks (no hooks /
400
+ settings.json / cache). Filesystem-only — deliberately NOT PATH-aware
401
+ (unlike :func:`_setup_compute_symlink_state`), so it stays
402
+ deterministic regardless of the live ``PATH``.
403
+ """
404
+ names = _cctally().SETUP_SYMLINK_NAMES
405
+ present = [n for n in names if (dst_dir / n).is_symlink()]
406
+ if not present:
407
+ return _RepairResult(gated=True, created=[], failed=[])
408
+ missing = [
409
+ n for n in names
410
+ if not (dst_dir / n).is_symlink() and not (dst_dir / n).exists()
411
+ ]
412
+ if not missing:
413
+ return _RepairResult(gated=False, created=[], failed=[])
414
+ results = _setup_create_symlinks(repo_root, dst_dir, names=tuple(missing))
415
+ created = [r.name for r in results if r.status == "created"]
416
+ failed = [(r.name, r.detail) for r in results if r.status == "failed"]
417
+ return _RepairResult(gated=False, created=created, failed=failed)
418
+
419
+
376
420
  def _setup_cleanup_stale_symlinks(
377
421
  dst_dir: pathlib.Path,
378
422
  ) -> list[_SetupSymlinkResult]:
@@ -408,19 +452,23 @@ def _setup_cleanup_stale_symlinks(
408
452
  """
409
453
  results: list[_SetupSymlinkResult] = []
410
454
  repo_root = _setup_resolve_repo_root()
411
- 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)):
412
457
  dst = dst_dir / name
413
458
  if not _setup_symlink_is_retired(dst, name, repo_root):
414
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
415
467
  try:
416
468
  dst.unlink()
417
- results.append(
418
- _SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
419
- )
469
+ results.append(_SetupSymlinkResult(name, "removed-stale", "stale (issue #119 cleanup)"))
420
470
  except OSError as exc:
421
- results.append(
422
- _SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
423
- )
471
+ results.append(_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}"))
424
472
  return results
425
473
 
426
474
 
@@ -484,7 +532,11 @@ def _setup_symlink_is_retired(
484
532
  target = os.readlink(dst)
485
533
  except OSError:
486
534
  return False
487
- 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:
488
540
  # User-managed symlink that happens to share the name; leave alone.
489
541
  return False
490
542
  # Resolve target relative to the symlink's parent so relative
@@ -508,9 +560,30 @@ def _setup_symlink_is_retired(
508
560
  # *from* an npm/brew install doesn't classify its own siblings as
509
561
  # foreign — that's the active-symlinks loop's job.
510
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).
511
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
+ )
512
576
  for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
513
- 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:
514
587
  return True
515
588
  return False
516
589
 
@@ -520,6 +593,44 @@ def _setup_path_includes_local_bin() -> bool:
520
593
  return local_bin in os.environ.get("PATH", "").split(os.pathsep)
521
594
 
522
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
+
523
634
  def _setup_shell_rc_hint() -> str:
524
635
  shell = os.environ.get("SHELL", "")
525
636
  if "zsh" in shell:
@@ -542,7 +653,16 @@ def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
542
653
  text = path.read_text(encoding="utf-8", errors="replace")
543
654
  except OSError:
544
655
  continue
545
- 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
+ ]
546
666
  if hits:
547
667
  return (path, hits)
548
668
  return None
@@ -970,29 +1090,25 @@ def _setup_compute_symlink_state(
970
1090
  ) -> "list[tuple[str, str]]":
971
1091
  """Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
972
1092
 
973
- state ∈ {"ok", "wrong", "missing"}:
974
- - "ok": ``dst_dir/name`` is a symlink whose target is reachable.
975
- The target is NOT required to match ``repo_root/bin/<name>`` —
976
- power users routinely have the symlinks installed by one
977
- cctally channel (npm/brew) while running ``doctor`` from a
978
- parallel source clone, which would otherwise produce a 0/N
979
- false negative on a perfectly healthy install. The diagnostic
980
- question is "is `cctally-X` invokable from PATH?", not "did
981
- THIS checkout install the symlink?". ``_setup_create_symlinks``
982
- keeps its own strict equality check for install-management
983
- (replace-vs-already).
984
- - "wrong": a non-symlink file occupies the slot, or the symlink
985
- target is dangling.
986
- - "missing": nothing at ``dst_dir/name``.
987
-
988
- ``repo_root`` is unused here — retained on the signature for
989
- 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.
990
1103
  """
991
- del repo_root # unused; see docstring
992
1104
  out: list[tuple[str, str]] = []
993
1105
  for name in _cctally().SETUP_SYMLINK_NAMES:
994
1106
  dst = dst_dir / name
1107
+ reachable = _reachable_elsewhere(name)
995
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
996
1112
  try:
997
1113
  dst.resolve(strict=True)
998
1114
  out.append((name, "ok"))
@@ -1000,6 +1116,8 @@ def _setup_compute_symlink_state(
1000
1116
  out.append((name, "wrong"))
1001
1117
  elif dst.exists():
1002
1118
  out.append((name, "wrong"))
1119
+ elif reachable:
1120
+ out.append((name, "ok"))
1003
1121
  else:
1004
1122
  out.append((name, "missing"))
1005
1123
  return out
@@ -1035,8 +1153,11 @@ def _setup_status(args: argparse.Namespace) -> int:
1035
1153
  repo_root = _setup_resolve_repo_root()
1036
1154
  dst_dir = _setup_local_bin_dir()
1037
1155
  sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
1038
- sym_ok = sum(1 for _, s in sym_state if s == "ok")
1039
- 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)
1040
1161
  on_path = _setup_path_includes_local_bin()
1041
1162
  try:
1042
1163
  settings = c._load_claude_settings()
@@ -1084,14 +1205,18 @@ def _setup_status(args: argparse.Namespace) -> int:
1084
1205
  out: list[str] = []
1085
1206
  out.append("Install")
1086
1207
  sym_marker = "✓" if sym_ok == len(c.SETUP_SYMLINK_NAMES) else "✗"
1087
- out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} present at {dst_dir}/ {sym_marker}")
1208
+ out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} available at {dst_dir}/ {sym_marker}")
1088
1209
  if stale_syms:
1089
1210
  out.append(
1090
1211
  f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
1091
1212
  )
1092
1213
  out.append(" run `cctally setup` to remove")
1093
- out.append(f" PATH includes {'yes' if on_path else 'no'} "
1094
- 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 '⚠'}")
1095
1220
  out.append(f"Hooks ({_cctally_core.CLAUDE_SETTINGS_PATH})")
1096
1221
  for ev in c.SETUP_HOOK_EVENTS:
1097
1222
  marker = "✓" if hook_counts[ev] >= 1 else "✗"
@@ -1331,14 +1456,20 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1331
1456
  new = sum(1 for _, s in sym_results if s == "would-create")
1332
1457
  same = sum(1 for _, s in sym_results if s == "already")
1333
1458
  blocked = [name for name, s in sym_results if s == "blocked"]
1459
+ is_brew = _setup_is_brew_install(repo_root)
1334
1460
  out: list[str] = []
1335
- out.append(
1336
- f"Would symlink {len(c.SETUP_SYMLINK_NAMES)} files to {dst_dir}/ "
1337
- f"({same} already correct, {new} new)"
1338
- )
1339
- if blocked:
1340
- out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
1341
- 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.")
1342
1473
 
1343
1474
  out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
1344
1475
  abs_path = str(_setup_resolve_hook_target(repo_root))
@@ -1410,13 +1541,18 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
1410
1541
  envelope = {
1411
1542
  "schema_version": 1,
1412
1543
  "mode": "dry-run",
1413
- "symlinks": {
1414
- "would_create": new,
1415
- "already": same,
1416
- "blocked": blocked,
1417
- "destination": str(dst_dir),
1418
- "total": len(c.SETUP_SYMLINK_NAMES),
1419
- },
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
+ ),
1420
1556
  "hooks": {
1421
1557
  "would_add": [
1422
1558
  {
@@ -1608,24 +1744,40 @@ def _setup_install(args: argparse.Namespace) -> int:
1608
1744
  # disturbs a user's hand-rolled symlink sharing the name.
1609
1745
  stale_results = _setup_cleanup_stale_symlinks(dst_dir)
1610
1746
 
1611
- sym_results = _setup_create_symlinks(repo_root, dst_dir)
1612
- failed = [r for r in sym_results if r.status == "failed"]
1613
- if failed:
1614
- for r in failed:
1615
- eprint(f"setup: symlink {r.name} failed: {r.detail}")
1616
- return 1
1617
- new_count = sum(1 for r in sym_results if r.status == "created")
1618
- same_count = sum(1 for r in sym_results if r.status == "already")
1619
- repl_count = sum(1 for r in sym_results if r.status == "replaced")
1620
- detail_parts = []
1621
- if new_count:
1622
- detail_parts.append(f"{new_count} newly created")
1623
- if same_count:
1624
- detail_parts.append(f"{same_count} already correct")
1625
- if repl_count:
1626
- detail_parts.append(f"{repl_count} re-pointed")
1627
- detail = ", ".join(detail_parts) or "no changes"
1628
- 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})")
1629
1781
  removed_stale = [r for r in stale_results if r.status == "removed-stale"]
1630
1782
  failed_stale = [r for r in stale_results if r.status == "failed"]
1631
1783
  if removed_stale:
@@ -1637,7 +1789,7 @@ def _setup_install(args: argparse.Namespace) -> int:
1637
1789
  out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
1638
1790
  warnings += 1
1639
1791
 
1640
- if not _setup_path_includes_local_bin():
1792
+ if not is_brew and not _setup_path_includes_local_bin():
1641
1793
  warnings += 1
1642
1794
  rc = _setup_shell_rc_hint()
1643
1795
  out.append(f"⚠ {dst_dir} is not on your PATH. Add to {rc}:")
@@ -1645,6 +1797,25 @@ def _setup_install(args: argparse.Namespace) -> int:
1645
1797
  out.append(" Then reload (`source ...`) or open a new terminal.")
1646
1798
  out.append(" (Hooks still work — we used absolute paths in settings.json.)")
1647
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
+
1648
1819
  c._backup_claude_settings()
1649
1820
  try:
1650
1821
  c._write_claude_settings_atomic(settings)
@@ -1813,8 +1984,11 @@ def _setup_install(args: argparse.Namespace) -> int:
1813
1984
  "created": new_count,
1814
1985
  "already": same_count,
1815
1986
  "replaced": repl_count,
1816
- "total": len(sym_results),
1987
+ "total": len(sym_results), # 0 on brew (sym_results == [])
1817
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 {}),
1818
1992
  },
1819
1993
  "hooks": {
1820
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} present",
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} present; 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
@@ -479,7 +479,10 @@ _SetupSymlinkResult = _cctally_setup._SetupSymlinkResult
479
479
  _setup_resolve_symlink_source = _cctally_setup._setup_resolve_symlink_source
480
480
  _setup_resolve_hook_target = _cctally_setup._setup_resolve_hook_target
481
481
  _setup_create_symlinks = _cctally_setup._setup_create_symlinks
482
+ _setup_repair_symlinks = _cctally_setup._setup_repair_symlinks
482
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
483
486
  _setup_shell_rc_hint = _cctally_setup._setup_shell_rc_hint
484
487
  _setup_detect_legacy_snippet = _cctally_setup._setup_detect_legacy_snippet
485
488
  _setup_detect_legacy_bespoke_hooks = _cctally_setup._setup_detect_legacy_bespoke_hooks
@@ -10405,6 +10408,39 @@ def doctor_gather_state(
10405
10408
  path_includes = _setup_path_includes_local_bin()
10406
10409
  except Exception:
10407
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
10408
10444
  try:
10409
10445
  legacy_snippet = _setup_detect_legacy_snippet()
10410
10446
  except Exception:
@@ -10687,6 +10723,10 @@ def doctor_gather_state(
10687
10723
  return _lib_doctor.DoctorState(
10688
10724
  symlink_state=symlink_state,
10689
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,
10690
10730
  legacy_snippet=legacy_snippet,
10691
10731
  legacy_bespoke=legacy_bespoke,
10692
10732
  claude_settings=settings,
@@ -13108,6 +13148,27 @@ def build_parser() -> argparse.ArgumentParser:
13108
13148
  )
13109
13149
  uc.set_defaults(func=cmd_update_check_internal)
13110
13150
 
13151
+ # ---- repair-symlinks (internal — hidden; npm-postinstall self-heal, issue #114) ----
13152
+ rs = sub.add_parser(
13153
+ "repair-symlinks",
13154
+ help=argparse.SUPPRESS,
13155
+ formatter_class=CLIHelpFormatter,
13156
+ description=textwrap.dedent(
13157
+ """\
13158
+ Internal subcommand: additively create any missing
13159
+ ~/.local/bin/ symlinks for cctally subcommands (issue #114).
13160
+
13161
+ Invoked best-effort by the npm postinstall on upgrade so new
13162
+ cctally-* binaries become reachable without re-running
13163
+ `cctally setup`. Gated to existing installs (>=1 symlink
13164
+ already present); a fresh install is a silent no-op. Touches
13165
+ only symlinks — no hooks, settings.json, or cache. Refuses
13166
+ from a dev checkout.
13167
+ """
13168
+ ),
13169
+ )
13170
+ rs.set_defaults(func=cmd_repair_symlinks)
13171
+
13111
13172
  # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
13112
13173
  # the pseudo-action so the row disappears entirely. (The choice still
13113
13174
  # appears in the `{...}` choices header — there's no clean way to hide
@@ -14921,6 +14982,33 @@ cmd_tui = _cctally_tui.cmd_tui
14921
14982
  _tui_render_once = _cctally_tui._tui_render_once
14922
14983
 
14923
14984
 
14985
+ def cmd_repair_symlinks(args: argparse.Namespace) -> int:
14986
+ """Hidden: additively create missing ~/.local/bin/ symlinks on upgrade.
14987
+
14988
+ Invoked best-effort by the npm postinstall (issue #114). Refuses from
14989
+ a dev checkout (would point ~/.local/bin at the dev tree). Touches
14990
+ only symlinks — see _setup_repair_symlinks. Exempted from main()'s
14991
+ post-command update hooks (see _post_command_update_hooks).
14992
+ """
14993
+ if _cctally_core._is_dev_checkout():
14994
+ eprint(
14995
+ "repair-symlinks: refusing to run from a dev checkout "
14996
+ "(would point ~/.local/bin at the dev tree)"
14997
+ )
14998
+ return 2
14999
+ repo_root = _setup_resolve_repo_root()
15000
+ dst_dir = _setup_local_bin_dir()
15001
+ result = _setup_repair_symlinks(repo_root, dst_dir)
15002
+ if result.created:
15003
+ print(
15004
+ f"cctally: linked {len(result.created)} new command symlink(s): "
15005
+ + ", ".join(result.created)
15006
+ )
15007
+ for name, detail in result.failed:
15008
+ eprint(f"repair-symlinks: {name}: {detail}")
15009
+ return 1 if result.failed else 0
15010
+
15011
+
14924
15012
  def main(argv: list[str] | None = None) -> int:
14925
15013
  _migrate_legacy_data_dir()
14926
15014
  parser = build_parser()
@@ -14994,11 +15082,21 @@ def _post_command_update_hooks(command: str | None, args) -> None:
14994
15082
  does not stop these side effects. Doctor reads update state for
14995
15083
  its report via the gather layer; it must not refresh that state
14996
15084
  opportunistically. Users who want a fresh check have
14997
- ``cctally update --check``."""
15085
+ ``cctally update --check``.
15086
+
15087
+ Skip-for-repair-symlinks: ``repair-symlinks`` is spawned by the npm
15088
+ postinstall on every install (issue #114). It must touch nothing but
15089
+ ~/.local/bin/ symlinks — running ``load_config()`` /
15090
+ ``_spawn_background_update_check`` here would create ``config.json``,
15091
+ ``update-state.json``, and ``update.log`` on a fresh install where
15092
+ the existing-install gate already makes the symlink work a no-op.
15093
+ Same rationale as doctor."""
14998
15094
  if command == "setup" and getattr(args, "uninstall", False):
14999
15095
  return
15000
15096
  if command == "doctor":
15001
15097
  return
15098
+ if command == "repair-symlinks":
15099
+ return
15002
15100
  # Self-heal: reconcile current_version with the running binary's
15003
15101
  # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
15004
15102
  # command after a manual upgrade). Runs before load_config so a
@@ -16,6 +16,20 @@ if (process.env.CCTALLY_NPM_POSTINSTALL_QUIET === '1') {
16
16
  process.exit(0);
17
17
  }
18
18
 
19
+ // Best-effort symlink self-heal on upgrade (issue #114): additively
20
+ // create ~/.local/bin/ symlinks for any new cctally-* subcommands so an
21
+ // upgrade doesn't strand them until the user re-runs `cctally setup`.
22
+ // MUST NOT fail the npm install — swallow every error, ignore exit code.
23
+ try {
24
+ const { spawnSync } = require('child_process');
25
+ const path = require('path');
26
+ const python = process.env.CCTALLY_PYTHON || 'python3';
27
+ const scriptPath = path.join(__dirname, 'cctally');
28
+ spawnSync(python, [scriptPath, 'repair-symlinks'], { stdio: 'inherit' });
29
+ } catch (_) {
30
+ // best-effort only
31
+ }
32
+
19
33
  process.stdout.write(
20
34
  '\ncctally installed.\n' +
21
35
  '\nTo finish setup, run:\n' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cctally",
3
- "version": "1.21.1",
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": {