cctally 1.21.1 → 1.21.2

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,14 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.21.2] - 2026-05-28
9
+
10
+ ### Fixed
11
+ - 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).
12
+
13
+ ### Changed
14
+ - `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).
15
+
8
16
  ## [1.21.1] - 2026-05-28
9
17
 
10
18
  ### 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`.
@@ -373,6 +373,44 @@ def _setup_create_symlinks(
373
373
  return results
374
374
 
375
375
 
376
+ @dataclasses.dataclass
377
+ class _RepairResult:
378
+ gated: bool
379
+ created: list[str]
380
+ failed: list[tuple[str, str]] # (name, detail)
381
+
382
+
383
+ def _setup_repair_symlinks(
384
+ repo_root: pathlib.Path, dst_dir: pathlib.Path,
385
+ ) -> _RepairResult:
386
+ """Additively reconcile ``dst_dir`` to ``SETUP_SYMLINK_NAMES`` (issue #114).
387
+
388
+ Existing-install gate: acts only when at least one
389
+ ``SETUP_SYMLINK_NAMES`` symlink is already present in ``dst_dir`` — a
390
+ fresh install (zero links) is left untouched so onboarding stays
391
+ opt-in. Strictly additive: creates only *genuinely empty* slots and
392
+ leaves present / wrong-target / dangling / non-symlink slots alone.
393
+ Touches nothing but ``~/.local/bin/`` symlinks (no hooks /
394
+ settings.json / cache). Filesystem-only — deliberately NOT PATH-aware
395
+ (unlike :func:`_setup_compute_symlink_state`), so it stays
396
+ deterministic regardless of the live ``PATH``.
397
+ """
398
+ names = _cctally().SETUP_SYMLINK_NAMES
399
+ present = [n for n in names if (dst_dir / n).is_symlink()]
400
+ if not present:
401
+ return _RepairResult(gated=True, created=[], failed=[])
402
+ missing = [
403
+ n for n in names
404
+ if not (dst_dir / n).is_symlink() and not (dst_dir / n).exists()
405
+ ]
406
+ if not missing:
407
+ return _RepairResult(gated=False, created=[], failed=[])
408
+ results = _setup_create_symlinks(repo_root, dst_dir, names=tuple(missing))
409
+ created = [r.name for r in results if r.status == "created"]
410
+ failed = [(r.name, r.detail) for r in results if r.status == "failed"]
411
+ return _RepairResult(gated=False, created=created, failed=failed)
412
+
413
+
376
414
  def _setup_cleanup_stale_symlinks(
377
415
  dst_dir: pathlib.Path,
378
416
  ) -> list[_SetupSymlinkResult]:
@@ -982,8 +1020,14 @@ def _setup_compute_symlink_state(
982
1020
  keeps its own strict equality check for install-management
983
1021
  (replace-vs-already).
984
1022
  - "wrong": a non-symlink file occupies the slot, or the symlink
985
- target is dangling.
986
- - "missing": nothing at ``dst_dir/name``.
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).
987
1031
 
988
1032
  ``repo_root`` is unused here — retained on the signature for
989
1033
  call-site stability across `_setup_status` and `doctor_gather_state`.
@@ -1000,6 +1044,12 @@ def _setup_compute_symlink_state(
1000
1044
  out.append((name, "wrong"))
1001
1045
  elif dst.exists():
1002
1046
  out.append((name, "wrong"))
1047
+ elif shutil.which(name):
1048
+ # Slot empty in dst_dir but the command is reachable on PATH
1049
+ # via another channel (e.g. brew's <prefix>/bin). The
1050
+ # diagnostic question is "is cctally-X invokable?", so treat
1051
+ # as ok rather than false-warning. (Issue #114.)
1052
+ out.append((name, "ok"))
1003
1053
  else:
1004
1054
  out.append((name, "missing"))
1005
1055
  return out
@@ -1084,7 +1134,7 @@ def _setup_status(args: argparse.Namespace) -> int:
1084
1134
  out: list[str] = []
1085
1135
  out.append("Install")
1086
1136
  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}")
1137
+ out.append(f" Symlinks {sym_ok}/{len(c.SETUP_SYMLINK_NAMES)} available at {dst_dir}/ {sym_marker}")
1088
1138
  if stale_syms:
1089
1139
  out.append(
1090
1140
  f" Stale symlinks {len(stale_syms)} from prior version: {', '.join(stale_syms)} ⚠"
@@ -157,14 +157,14 @@ def _check_install_symlinks(s: DoctorState) -> CheckResult:
157
157
  if not missing:
158
158
  return CheckResult(
159
159
  id="install.symlinks", title="Symlinks",
160
- severity="ok", summary=f"{ok_count}/{total} present",
160
+ severity="ok", summary=f"{ok_count}/{total} available",
161
161
  remediation=None,
162
162
  details={"present": ok_count, "total": total, "missing": []},
163
163
  )
164
164
  return CheckResult(
165
165
  id="install.symlinks", title="Symlinks",
166
166
  severity="warn",
167
- summary=f"{ok_count}/{total} present; missing {', '.join(missing)}",
167
+ summary=f"{ok_count}/{total} available; missing {', '.join(missing)}",
168
168
  remediation="Run `cctally setup`",
169
169
  details={"present": ok_count, "total": total, "missing": missing},
170
170
  )
package/bin/cctally CHANGED
@@ -479,6 +479,7 @@ _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
483
484
  _setup_shell_rc_hint = _cctally_setup._setup_shell_rc_hint
484
485
  _setup_detect_legacy_snippet = _cctally_setup._setup_detect_legacy_snippet
@@ -13108,6 +13109,27 @@ def build_parser() -> argparse.ArgumentParser:
13108
13109
  )
13109
13110
  uc.set_defaults(func=cmd_update_check_internal)
13110
13111
 
13112
+ # ---- repair-symlinks (internal — hidden; npm-postinstall self-heal, issue #114) ----
13113
+ rs = sub.add_parser(
13114
+ "repair-symlinks",
13115
+ help=argparse.SUPPRESS,
13116
+ formatter_class=CLIHelpFormatter,
13117
+ description=textwrap.dedent(
13118
+ """\
13119
+ Internal subcommand: additively create any missing
13120
+ ~/.local/bin/ symlinks for cctally subcommands (issue #114).
13121
+
13122
+ Invoked best-effort by the npm postinstall on upgrade so new
13123
+ cctally-* binaries become reachable without re-running
13124
+ `cctally setup`. Gated to existing installs (>=1 symlink
13125
+ already present); a fresh install is a silent no-op. Touches
13126
+ only symlinks — no hooks, settings.json, or cache. Refuses
13127
+ from a dev checkout.
13128
+ """
13129
+ ),
13130
+ )
13131
+ rs.set_defaults(func=cmd_repair_symlinks)
13132
+
13111
13133
  # Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
13112
13134
  # the pseudo-action so the row disappears entirely. (The choice still
13113
13135
  # appears in the `{...}` choices header — there's no clean way to hide
@@ -14921,6 +14943,33 @@ cmd_tui = _cctally_tui.cmd_tui
14921
14943
  _tui_render_once = _cctally_tui._tui_render_once
14922
14944
 
14923
14945
 
14946
+ def cmd_repair_symlinks(args: argparse.Namespace) -> int:
14947
+ """Hidden: additively create missing ~/.local/bin/ symlinks on upgrade.
14948
+
14949
+ Invoked best-effort by the npm postinstall (issue #114). Refuses from
14950
+ a dev checkout (would point ~/.local/bin at the dev tree). Touches
14951
+ only symlinks — see _setup_repair_symlinks. Exempted from main()'s
14952
+ post-command update hooks (see _post_command_update_hooks).
14953
+ """
14954
+ if _cctally_core._is_dev_checkout():
14955
+ eprint(
14956
+ "repair-symlinks: refusing to run from a dev checkout "
14957
+ "(would point ~/.local/bin at the dev tree)"
14958
+ )
14959
+ return 2
14960
+ repo_root = _setup_resolve_repo_root()
14961
+ dst_dir = _setup_local_bin_dir()
14962
+ result = _setup_repair_symlinks(repo_root, dst_dir)
14963
+ if result.created:
14964
+ print(
14965
+ f"cctally: linked {len(result.created)} new command symlink(s): "
14966
+ + ", ".join(result.created)
14967
+ )
14968
+ for name, detail in result.failed:
14969
+ eprint(f"repair-symlinks: {name}: {detail}")
14970
+ return 1 if result.failed else 0
14971
+
14972
+
14924
14973
  def main(argv: list[str] | None = None) -> int:
14925
14974
  _migrate_legacy_data_dir()
14926
14975
  parser = build_parser()
@@ -14994,11 +15043,21 @@ def _post_command_update_hooks(command: str | None, args) -> None:
14994
15043
  does not stop these side effects. Doctor reads update state for
14995
15044
  its report via the gather layer; it must not refresh that state
14996
15045
  opportunistically. Users who want a fresh check have
14997
- ``cctally update --check``."""
15046
+ ``cctally update --check``.
15047
+
15048
+ Skip-for-repair-symlinks: ``repair-symlinks`` is spawned by the npm
15049
+ postinstall on every install (issue #114). It must touch nothing but
15050
+ ~/.local/bin/ symlinks — running ``load_config()`` /
15051
+ ``_spawn_background_update_check`` here would create ``config.json``,
15052
+ ``update-state.json``, and ``update.log`` on a fresh install where
15053
+ the existing-install gate already makes the symlink work a no-op.
15054
+ Same rationale as doctor."""
14998
15055
  if command == "setup" and getattr(args, "uninstall", False):
14999
15056
  return
15000
15057
  if command == "doctor":
15001
15058
  return
15059
+ if command == "repair-symlinks":
15060
+ return
15002
15061
  # Self-heal: reconcile current_version with the running binary's
15003
15062
  # CHANGELOG. Cheap (one CHANGELOG read, write only on the first
15004
15063
  # 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.2",
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": {