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 +7 -0
- package/bin/_cctally_setup.py +215 -91
- package/bin/_lib_doctor.py +100 -14
- package/bin/cctally +39 -0
- package/package.json +1 -1
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
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -293,6 +293,12 @@ _SETUP_STALE_SYMLINK_NAMES = (
|
|
|
293
293
|
)
|
|
294
294
|
|
|
295
295
|
|
|
296
|
+
# The npm package ships a Node shim as the `cctally` entry point. Its
|
|
297
|
+
# basename differs from the command name, so any code matching a
|
|
298
|
+
# symlink's readlink target by basename must special-case it.
|
|
299
|
+
_CCTALLY_NPM_SHIM_BASENAME = "cctally-npm-shim.js"
|
|
300
|
+
|
|
301
|
+
|
|
296
302
|
def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib.Path:
|
|
297
303
|
"""Resolve the symlink target for a given PATH-name.
|
|
298
304
|
|
|
@@ -310,27 +316,27 @@ def _setup_resolve_symlink_source(repo_root: pathlib.Path, name: str) -> pathlib
|
|
|
310
316
|
documented. All other names map directly to ``bin/<name>``.
|
|
311
317
|
"""
|
|
312
318
|
if name == "cctally" and "node_modules" in repo_root.parts:
|
|
313
|
-
shim = repo_root / "bin" /
|
|
319
|
+
shim = repo_root / "bin" / _CCTALLY_NPM_SHIM_BASENAME
|
|
314
320
|
if shim.exists():
|
|
315
321
|
return shim
|
|
316
322
|
return repo_root / "bin" / name
|
|
317
323
|
|
|
318
324
|
|
|
319
325
|
def _setup_resolve_hook_target(repo_root: pathlib.Path) -> pathlib.Path:
|
|
320
|
-
"""
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
but every hook fire bypasses it via ``/usr/bin/env python3`` and
|
|
329
|
-
silently fails when the user's ``python3`` doesn't meet the version
|
|
330
|
-
floor. The returned path is fully resolved (symlinks followed) so
|
|
331
|
-
the recorded hook entry survives later filesystem rearrangements
|
|
332
|
-
of the source clone or the npm install root.
|
|
326
|
+
"""Absolute path recorded in Claude Code hook entries.
|
|
327
|
+
|
|
328
|
+
Brew installs: return the version-stable `<prefix>/bin/cctally` (the
|
|
329
|
+
formula's symlink, which self-heals on `brew upgrade`) WITHOUT
|
|
330
|
+
`.resolve()` — resolving would pin the hook to the versioned keg and
|
|
331
|
+
dangle after `brew cleanup` (issue #119). Source/npm: keep the
|
|
332
|
+
`.resolve()` semantics (survives clone/node_modules rearrangement and
|
|
333
|
+
the npm-shim branch in `_setup_resolve_symlink_source`).
|
|
333
334
|
"""
|
|
335
|
+
if _setup_is_brew_install(repo_root):
|
|
336
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
337
|
+
stable = pathlib.Path(prefix) / "bin" / "cctally"
|
|
338
|
+
if stable.exists():
|
|
339
|
+
return stable
|
|
334
340
|
return _setup_resolve_symlink_source(repo_root, "cctally").resolve()
|
|
335
341
|
|
|
336
342
|
|
|
@@ -446,19 +452,23 @@ def _setup_cleanup_stale_symlinks(
|
|
|
446
452
|
"""
|
|
447
453
|
results: list[_SetupSymlinkResult] = []
|
|
448
454
|
repo_root = _setup_resolve_repo_root()
|
|
449
|
-
|
|
455
|
+
retired_names = set(_SETUP_STALE_SYMLINK_NAMES)
|
|
456
|
+
for name in dict.fromkeys(_SETUP_STALE_SYMLINK_NAMES + tuple(_cctally().SETUP_SYMLINK_NAMES)):
|
|
450
457
|
dst = dst_dir / name
|
|
451
458
|
if not _setup_symlink_is_retired(dst, name, repo_root):
|
|
452
459
|
continue
|
|
460
|
+
if name in retired_names:
|
|
461
|
+
should_remove = True # retired command: unconditional
|
|
462
|
+
else: # active name: reachability-gated
|
|
463
|
+
is_dangling = not dst.resolve(strict=False).exists() if dst.is_symlink() else False
|
|
464
|
+
should_remove = is_dangling or _reachable_elsewhere(name)
|
|
465
|
+
if not should_remove:
|
|
466
|
+
continue
|
|
453
467
|
try:
|
|
454
468
|
dst.unlink()
|
|
455
|
-
results.append(
|
|
456
|
-
_SetupSymlinkResult(name, "removed-stale", "stale (from prior version)")
|
|
457
|
-
)
|
|
469
|
+
results.append(_SetupSymlinkResult(name, "removed-stale", "stale (issue #119 cleanup)"))
|
|
458
470
|
except OSError as exc:
|
|
459
|
-
results.append(
|
|
460
|
-
_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}")
|
|
461
|
-
)
|
|
471
|
+
results.append(_SetupSymlinkResult(name, "failed", f"unlink failed: {exc}"))
|
|
462
472
|
return results
|
|
463
473
|
|
|
464
474
|
|
|
@@ -522,7 +532,11 @@ def _setup_symlink_is_retired(
|
|
|
522
532
|
target = os.readlink(dst)
|
|
523
533
|
except OSError:
|
|
524
534
|
return False
|
|
525
|
-
|
|
535
|
+
target_basename = pathlib.Path(target).name
|
|
536
|
+
accepted = {name}
|
|
537
|
+
if name == "cctally":
|
|
538
|
+
accepted.add(_CCTALLY_NPM_SHIM_BASENAME)
|
|
539
|
+
if target_basename not in accepted:
|
|
526
540
|
# User-managed symlink that happens to share the name; leave alone.
|
|
527
541
|
return False
|
|
528
542
|
# Resolve target relative to the symlink's parent so relative
|
|
@@ -546,9 +560,30 @@ def _setup_symlink_is_retired(
|
|
|
546
560
|
# *from* an npm/brew install doesn't classify its own siblings as
|
|
547
561
|
# foreign — that's the active-symlinks loop's job.
|
|
548
562
|
target_str = str(target_path)
|
|
563
|
+
# "Same install root" means the target lives UNDER the current
|
|
564
|
+
# ``repo_root`` tree. Use a separator-anchored prefix check rather
|
|
565
|
+
# than a bare substring of the token: a same-``repo_root`` npm install
|
|
566
|
+
# has ``repo_root == <…>/node_modules/cctally`` (no trailing slash),
|
|
567
|
+
# so the raw ``"/node_modules/cctally/" in repo_root_str`` test would
|
|
568
|
+
# spuriously miss and classify the live channel's own link as foreign
|
|
569
|
+
# (issue #119 finding #7 — the npm `cctally` shim under its own
|
|
570
|
+
# ``node_modules/cctally`` must be preserved, not retired).
|
|
549
571
|
repo_root_str = str(repo_root)
|
|
572
|
+
repo_root_prefix = repo_root_str.rstrip(os.sep) + os.sep
|
|
573
|
+
target_under_repo_root = (
|
|
574
|
+
target_str == repo_root_str or target_str.startswith(repo_root_prefix)
|
|
575
|
+
)
|
|
550
576
|
for token in _SETUP_FOREIGN_INSTALL_ROOT_TOKENS:
|
|
551
|
-
if token
|
|
577
|
+
if token not in target_str:
|
|
578
|
+
continue
|
|
579
|
+
# Homebrew keg links are NEVER owned by ~/.local/bin under the
|
|
580
|
+
# issue-#119 policy, so retire them regardless of which keg /
|
|
581
|
+
# whether repo_root is itself a keg. Other foreign roots
|
|
582
|
+
# (node_modules) retire only when the target is NOT under the
|
|
583
|
+
# current install root.
|
|
584
|
+
if token == _SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0]: # "/Cellar/cctally/"
|
|
585
|
+
return True
|
|
586
|
+
if not target_under_repo_root:
|
|
552
587
|
return True
|
|
553
588
|
return False
|
|
554
589
|
|
|
@@ -558,6 +593,44 @@ def _setup_path_includes_local_bin() -> bool:
|
|
|
558
593
|
return local_bin in os.environ.get("PATH", "").split(os.pathsep)
|
|
559
594
|
|
|
560
595
|
|
|
596
|
+
def _setup_is_brew_install(repo_root: pathlib.Path) -> bool:
|
|
597
|
+
"""True when this cctally runs from a Homebrew keg.
|
|
598
|
+
|
|
599
|
+
`_setup_resolve_repo_root()` `.resolve()`s `__file__`, so a brew
|
|
600
|
+
install reliably carries the `/Cellar/cctally/` token (Apple Silicon,
|
|
601
|
+
Intel, Linuxbrew all funnel through it). Reuses the single token
|
|
602
|
+
source in `_SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0]`. Cheap — no
|
|
603
|
+
`npm prefix` subprocess; we only need the brew yes/no.
|
|
604
|
+
"""
|
|
605
|
+
return _SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0] in str(repo_root)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _setup_brew_prefix(repo_root: pathlib.Path) -> str:
|
|
609
|
+
"""The Homebrew `<prefix>` (e.g. `/opt/homebrew`) for a brew keg
|
|
610
|
+
`repo_root`. Splits on the single-source brew token so we never spell
|
|
611
|
+
the Cellar path a third way. Callers must guard with
|
|
612
|
+
`_setup_is_brew_install(repo_root)` first; off a keg this returns the
|
|
613
|
+
unchanged string."""
|
|
614
|
+
return str(repo_root).split(_SETUP_FOREIGN_INSTALL_ROOT_TOKENS[0])[0]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _reachable_elsewhere(name: str) -> bool:
|
|
618
|
+
"""Would `<name>` still be found on PATH if the ~/.local/bin slot
|
|
619
|
+
didn't exist? Excludes the ~/.local/bin directory (realpath-compared)
|
|
620
|
+
so a stale link can't satisfy its own reachability check (issue #119
|
|
621
|
+
finding #6)."""
|
|
622
|
+
local_bin = _setup_local_bin_dir()
|
|
623
|
+
try:
|
|
624
|
+
local_real = os.path.realpath(local_bin)
|
|
625
|
+
except OSError:
|
|
626
|
+
local_real = str(local_bin)
|
|
627
|
+
dirs = [
|
|
628
|
+
d for d in os.environ.get("PATH", "").split(os.pathsep)
|
|
629
|
+
if d and os.path.realpath(d) != local_real
|
|
630
|
+
]
|
|
631
|
+
return shutil.which(name, path=os.pathsep.join(dirs)) is not None
|
|
632
|
+
|
|
633
|
+
|
|
561
634
|
def _setup_shell_rc_hint() -> str:
|
|
562
635
|
shell = os.environ.get("SHELL", "")
|
|
563
636
|
if "zsh" in shell:
|
|
@@ -580,7 +653,16 @@ def _setup_detect_legacy_snippet() -> tuple[pathlib.Path, list[int]] | None:
|
|
|
580
653
|
text = path.read_text(encoding="utf-8", errors="replace")
|
|
581
654
|
except OSError:
|
|
582
655
|
continue
|
|
583
|
-
|
|
656
|
+
# Skip lines whose first non-whitespace char is `#`: a shell comment
|
|
657
|
+
# that merely references the legacy command in prose (e.g. a NOTE
|
|
658
|
+
# documenting its removal) is not an executing snippet (issue #115).
|
|
659
|
+
# POSIX shell only treats `#` as a comment marker at start-of-token,
|
|
660
|
+
# so this covers natural-language comments without parsing the shell.
|
|
661
|
+
hits = [
|
|
662
|
+
i + 1
|
|
663
|
+
for i, ln in enumerate(text.splitlines())
|
|
664
|
+
if c.LEGACY_STATUSLINE_NEEDLE in ln and not ln.lstrip().startswith("#")
|
|
665
|
+
]
|
|
584
666
|
if hits:
|
|
585
667
|
return (path, hits)
|
|
586
668
|
return None
|
|
@@ -1008,35 +1090,25 @@ def _setup_compute_symlink_state(
|
|
|
1008
1090
|
) -> "list[tuple[str, str]]":
|
|
1009
1091
|
"""Per-symlink (name, state) for `_setup_status` + `doctor_gather_state`.
|
|
1010
1092
|
|
|
1011
|
-
state ∈ {"ok", "wrong", "missing"}
|
|
1012
|
-
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
(replace-vs-already).
|
|
1022
|
-
- "wrong": a non-symlink file occupies the slot, or the symlink
|
|
1023
|
-
target is dangling. Unchanged by the PATH-aware fallback below —
|
|
1024
|
-
a dangling/occupied slot stays "wrong" even if the command is
|
|
1025
|
-
reachable elsewhere (that's the §9 brew-cleanup follow-up's job).
|
|
1026
|
-
- "missing": nothing at ``dst_dir/name`` AND the command is not
|
|
1027
|
-
reachable on ``$PATH`` via another channel. When the slot is
|
|
1028
|
-
empty but ``shutil.which(name)`` resolves (e.g. a brew
|
|
1029
|
-
``<prefix>/bin`` install), the state is "ok" instead — the
|
|
1030
|
-
diagnostic question is "is cctally-X invokable?" (issue #114).
|
|
1031
|
-
|
|
1032
|
-
``repo_root`` is unused here — retained on the signature for
|
|
1033
|
-
call-site stability across `_setup_status` and `doctor_gather_state`.
|
|
1093
|
+
state ∈ {"ok", "stale", "wrong", "missing"}.
|
|
1094
|
+
- ok: resolvable non-retired link, OR empty slot reachable elsewhere.
|
|
1095
|
+
- stale: retired link (Cellar/foreign/dangling) AND command reachable
|
|
1096
|
+
via a non-~/.local/bin dir (safely cleanable). Issue #119.
|
|
1097
|
+
- wrong: non-symlink file in slot; dangling non-retired link; OR a
|
|
1098
|
+
retired link with no reachable_elsewhere fallback (broken, or
|
|
1099
|
+
the pathological only-path-live-Cellar case).
|
|
1100
|
+
- missing: empty slot, not reachable elsewhere.
|
|
1101
|
+
Retired check precedes the resolve()->ok branch so a LIVE Cellar link is
|
|
1102
|
+
classed stale, not masked as ok.
|
|
1034
1103
|
"""
|
|
1035
|
-
del repo_root # unused; see docstring
|
|
1036
1104
|
out: list[tuple[str, str]] = []
|
|
1037
1105
|
for name in _cctally().SETUP_SYMLINK_NAMES:
|
|
1038
1106
|
dst = dst_dir / name
|
|
1107
|
+
reachable = _reachable_elsewhere(name)
|
|
1039
1108
|
if dst.is_symlink():
|
|
1109
|
+
if _setup_symlink_is_retired(dst, name, repo_root):
|
|
1110
|
+
out.append((name, "stale" if reachable else "wrong"))
|
|
1111
|
+
continue
|
|
1040
1112
|
try:
|
|
1041
1113
|
dst.resolve(strict=True)
|
|
1042
1114
|
out.append((name, "ok"))
|
|
@@ -1044,11 +1116,7 @@ def _setup_compute_symlink_state(
|
|
|
1044
1116
|
out.append((name, "wrong"))
|
|
1045
1117
|
elif dst.exists():
|
|
1046
1118
|
out.append((name, "wrong"))
|
|
1047
|
-
elif
|
|
1048
|
-
# Slot empty in dst_dir but the command is reachable on PATH
|
|
1049
|
-
# via another channel (e.g. brew's <prefix>/bin). The
|
|
1050
|
-
# diagnostic question is "is cctally-X invokable?", so treat
|
|
1051
|
-
# as ok rather than false-warning. (Issue #114.)
|
|
1119
|
+
elif reachable:
|
|
1052
1120
|
out.append((name, "ok"))
|
|
1053
1121
|
else:
|
|
1054
1122
|
out.append((name, "missing"))
|
|
@@ -1085,8 +1153,11 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1085
1153
|
repo_root = _setup_resolve_repo_root()
|
|
1086
1154
|
dst_dir = _setup_local_bin_dir()
|
|
1087
1155
|
sym_state = _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1088
|
-
sym_ok = sum(1 for _, s in sym_state if s
|
|
1089
|
-
|
|
1156
|
+
sym_ok = sum(1 for _, s in sym_state if s in ("ok", "stale")) # available = ok + stale
|
|
1157
|
+
active_stale = [n for n, s in sym_state if s == "stale"]
|
|
1158
|
+
retired_stale = _setup_detect_stale_symlinks(dst_dir)
|
|
1159
|
+
stale_syms = list(dict.fromkeys(active_stale + retired_stale)) # union, order-stable
|
|
1160
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1090
1161
|
on_path = _setup_path_includes_local_bin()
|
|
1091
1162
|
try:
|
|
1092
1163
|
settings = c._load_claude_settings()
|
|
@@ -1140,8 +1211,12 @@ def _setup_status(args: argparse.Namespace) -> int:
|
|
|
1140
1211
|
f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
|
|
1141
1212
|
)
|
|
1142
1213
|
out.append(" run `cctally setup` to remove")
|
|
1143
|
-
|
|
1144
|
-
|
|
1214
|
+
if is_brew and (on_path or any(s in ("ok", "stale") for _, s in sym_state)):
|
|
1215
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1216
|
+
out.append(f" PATH brew: commands via {prefix}/bin ✓")
|
|
1217
|
+
else:
|
|
1218
|
+
out.append(f" PATH includes {'yes' if on_path else 'no'} "
|
|
1219
|
+
f"{'✓' if on_path else '⚠'}")
|
|
1145
1220
|
out.append(f"Hooks ({_cctally_core.CLAUDE_SETTINGS_PATH})")
|
|
1146
1221
|
for ev in c.SETUP_HOOK_EVENTS:
|
|
1147
1222
|
marker = "✓" if hook_counts[ev] >= 1 else "✗"
|
|
@@ -1381,14 +1456,20 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1381
1456
|
new = sum(1 for _, s in sym_results if s == "would-create")
|
|
1382
1457
|
same = sum(1 for _, s in sym_results if s == "already")
|
|
1383
1458
|
blocked = [name for name, s in sym_results if s == "blocked"]
|
|
1459
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1384
1460
|
out: list[str] = []
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
f"
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
out.append(
|
|
1391
|
-
|
|
1461
|
+
if is_brew:
|
|
1462
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1463
|
+
out.append(f"Brew install — would skip ~/.local/bin/ symlinks "
|
|
1464
|
+
f"(commands on PATH via {prefix}/bin/)")
|
|
1465
|
+
else:
|
|
1466
|
+
out.append(
|
|
1467
|
+
f"Would symlink {len(c.SETUP_SYMLINK_NAMES)} files to {dst_dir}/ "
|
|
1468
|
+
f"({same} already correct, {new} new)"
|
|
1469
|
+
)
|
|
1470
|
+
if blocked:
|
|
1471
|
+
out.append(f"⚠ Blocked (non-symlink files exist): {', '.join(blocked)}")
|
|
1472
|
+
out.append(" Remove them manually then re-run.")
|
|
1392
1473
|
|
|
1393
1474
|
out.append(f"Would add {len(c.SETUP_HOOK_EVENTS)} hook entries to {_cctally_core.CLAUDE_SETTINGS_PATH}:")
|
|
1394
1475
|
abs_path = str(_setup_resolve_hook_target(repo_root))
|
|
@@ -1460,13 +1541,18 @@ def _setup_dry_run(args: argparse.Namespace) -> int:
|
|
|
1460
1541
|
envelope = {
|
|
1461
1542
|
"schema_version": 1,
|
|
1462
1543
|
"mode": "dry-run",
|
|
1463
|
-
"symlinks":
|
|
1464
|
-
"would_create":
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1544
|
+
"symlinks": (
|
|
1545
|
+
{"skipped": True, "reason": "brew", "would_create": 0,
|
|
1546
|
+
"already": 0, "blocked": [], "destination": str(dst_dir),
|
|
1547
|
+
"total": 0,
|
|
1548
|
+
"would_remove_stale": [
|
|
1549
|
+
n for n, s in _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1550
|
+
if s == "stale"
|
|
1551
|
+
]}
|
|
1552
|
+
if is_brew else
|
|
1553
|
+
{"would_create": new, "already": same, "blocked": blocked,
|
|
1554
|
+
"destination": str(dst_dir), "total": len(c.SETUP_SYMLINK_NAMES)}
|
|
1555
|
+
),
|
|
1470
1556
|
"hooks": {
|
|
1471
1557
|
"would_add": [
|
|
1472
1558
|
{
|
|
@@ -1658,24 +1744,40 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1658
1744
|
# disturbs a user's hand-rolled symlink sharing the name.
|
|
1659
1745
|
stale_results = _setup_cleanup_stale_symlinks(dst_dir)
|
|
1660
1746
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1747
|
+
# Issue #119: brew owns <prefix>/bin/, never ~/.local/bin/. On a brew
|
|
1748
|
+
# install, skip symlink CREATION entirely (commands reach PATH via the
|
|
1749
|
+
# formula's <prefix>/bin/) but keep everything else — cleanup, hook
|
|
1750
|
+
# wiring, cache bootstrap. The new/same/repl counts are initialized to
|
|
1751
|
+
# 0 here so the install JSON envelope references are always bound on
|
|
1752
|
+
# the brew branch (sym_results stays empty).
|
|
1753
|
+
is_brew = _setup_is_brew_install(repo_root)
|
|
1754
|
+
new_count = same_count = repl_count = 0
|
|
1755
|
+
if is_brew:
|
|
1756
|
+
prefix = _setup_brew_prefix(repo_root)
|
|
1757
|
+
out.append(
|
|
1758
|
+
f"✓ Brew install detected — commands are on PATH via {prefix}/bin/; "
|
|
1759
|
+
f"skipping ~/.local/bin/ symlinks"
|
|
1760
|
+
)
|
|
1761
|
+
sym_results = []
|
|
1762
|
+
else:
|
|
1763
|
+
sym_results = _setup_create_symlinks(repo_root, dst_dir)
|
|
1764
|
+
failed = [r for r in sym_results if r.status == "failed"]
|
|
1765
|
+
if failed:
|
|
1766
|
+
for r in failed:
|
|
1767
|
+
eprint(f"setup: symlink {r.name} failed: {r.detail}")
|
|
1768
|
+
return 1
|
|
1769
|
+
new_count = sum(1 for r in sym_results if r.status == "created")
|
|
1770
|
+
same_count = sum(1 for r in sym_results if r.status == "already")
|
|
1771
|
+
repl_count = sum(1 for r in sym_results if r.status == "replaced")
|
|
1772
|
+
detail_parts = []
|
|
1773
|
+
if new_count:
|
|
1774
|
+
detail_parts.append(f"{new_count} newly created")
|
|
1775
|
+
if same_count:
|
|
1776
|
+
detail_parts.append(f"{same_count} already correct")
|
|
1777
|
+
if repl_count:
|
|
1778
|
+
detail_parts.append(f"{repl_count} re-pointed")
|
|
1779
|
+
detail = ", ".join(detail_parts) or "no changes"
|
|
1780
|
+
out.append(f"✓ Symlinks at {dst_dir}/: {len(sym_results)}/{len(sym_results)} ({detail})")
|
|
1679
1781
|
removed_stale = [r for r in stale_results if r.status == "removed-stale"]
|
|
1680
1782
|
failed_stale = [r for r in stale_results if r.status == "failed"]
|
|
1681
1783
|
if removed_stale:
|
|
@@ -1687,7 +1789,7 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1687
1789
|
out.append(f"⚠ Could not remove stale {r.name}: {r.detail}")
|
|
1688
1790
|
warnings += 1
|
|
1689
1791
|
|
|
1690
|
-
if not _setup_path_includes_local_bin():
|
|
1792
|
+
if not is_brew and not _setup_path_includes_local_bin():
|
|
1691
1793
|
warnings += 1
|
|
1692
1794
|
rc = _setup_shell_rc_hint()
|
|
1693
1795
|
out.append(f"⚠ {dst_dir} is not on your PATH. Add to {rc}:")
|
|
@@ -1695,6 +1797,25 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1695
1797
|
out.append(" Then reload (`source ...`) or open a new terminal.")
|
|
1696
1798
|
out.append(" (Hooks still work — we used absolute paths in settings.json.)")
|
|
1697
1799
|
|
|
1800
|
+
# Pinned-only-path guidance (issue #119 finding #10): if any active
|
|
1801
|
+
# name's slot is a live retired link with no reachable_elsewhere
|
|
1802
|
+
# fallback, setup deliberately won't remove it (would break the only
|
|
1803
|
+
# reachable copy) — surface the actionable PATH fix instead of silence.
|
|
1804
|
+
pinned = [
|
|
1805
|
+
n for n, s in _setup_compute_symlink_state(repo_root, dst_dir)
|
|
1806
|
+
if s == "wrong" and (dst_dir / n).is_symlink()
|
|
1807
|
+
and _setup_symlink_is_retired(dst_dir / n, n, repo_root)
|
|
1808
|
+
and (dst_dir / n).resolve(strict=False).exists()
|
|
1809
|
+
]
|
|
1810
|
+
if pinned:
|
|
1811
|
+
prefix = _setup_brew_prefix(repo_root) if is_brew else "<prefix>"
|
|
1812
|
+
out.append(
|
|
1813
|
+
f"⚠ cctally is reachable only via a legacy ~/.local/bin link. "
|
|
1814
|
+
f"Put {prefix}/bin on your PATH (eval \"$(brew shellenv)\"), then "
|
|
1815
|
+
f"re-run cctally setup to clean it."
|
|
1816
|
+
)
|
|
1817
|
+
warnings += 1
|
|
1818
|
+
|
|
1698
1819
|
c._backup_claude_settings()
|
|
1699
1820
|
try:
|
|
1700
1821
|
c._write_claude_settings_atomic(settings)
|
|
@@ -1863,8 +1984,11 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
1863
1984
|
"created": new_count,
|
|
1864
1985
|
"already": same_count,
|
|
1865
1986
|
"replaced": repl_count,
|
|
1866
|
-
"total": len(sym_results),
|
|
1987
|
+
"total": len(sym_results), # 0 on brew (sym_results == [])
|
|
1867
1988
|
"destination": str(dst_dir),
|
|
1989
|
+
**({"skipped": True, "reason": "brew",
|
|
1990
|
+
"stale_removed": [r.name for r in stale_results if r.status == "removed-stale"]}
|
|
1991
|
+
if is_brew else {}),
|
|
1868
1992
|
},
|
|
1869
1993
|
"hooks": {
|
|
1870
1994
|
"events_added": list(c.SETUP_HOOK_EVENTS),
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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"{
|
|
161
|
-
remediation=None,
|
|
162
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
183
|
-
remediation=
|
|
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.
|
|
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": {
|