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 +15 -0
- package/bin/_cctally_db.py +1 -0
- package/bin/_cctally_setup.py +255 -81
- package/bin/_lib_doctor.py +100 -14
- package/bin/cctally +99 -1
- package/bin/cctally-npm-postinstall.js +14 -0
- package/package.json +1 -1
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
|
package/bin/_cctally_db.py
CHANGED
|
@@ -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`.
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
1039
|
-
|
|
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)}
|
|
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
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
f"
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
out.append(
|
|
1341
|
-
|
|
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":
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
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
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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),
|
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} 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
|
-
|
|
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
|
@@ -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.
|
|
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": {
|