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 +8 -0
- package/bin/_cctally_db.py +1 -0
- package/bin/_cctally_setup.py +53 -3
- package/bin/_lib_doctor.py +2 -2
- package/bin/cctally +60 -1
- package/bin/cctally-npm-postinstall.js +14 -0
- package/package.json +1 -1
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
|
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
|
@@ -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
|
-
|
|
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)}
|
|
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)} ⚠"
|
package/bin/_lib_doctor.py
CHANGED
|
@@ -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}
|
|
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}
|
|
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.
|
|
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": {
|