cctally 1.21.0 → 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 +13 -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 +109 -11
- package/bin/cctally-npm-postinstall.js +14 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ 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
|
+
|
|
16
|
+
## [1.21.1] - 2026-05-28
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
- **`cctally statusline` now shows the correct 7d percentage after an Anthropic mid-week reset (or in-place credit) instead of staying pinned to the pre-reset peak.** The statusline's 7d number is monotonic-clamped against a high-water mark read from `weekly_usage_snapshots`, but the clamp keyed only on `week_start_date` — `SELECT MAX(weekly_percent) WHERE week_start_date = ?`. When Anthropic resets a 7d window mid-cycle, the post-reset snapshots keep the *same* `week_start_date` (and `week_end_at`) as the pre-reset ramp, so the bucket-wide `MAX` returned the stale pre-reset peak (e.g. 41%) and clamped the true post-reset value (e.g. 2%) back up to it; the statusline read e.g. `7d 41%` while `cctally report`/`forecast` and the dashboard — which segment the week at the reset via `week_reset_events` — correctly showed 2%. The 5h segment was never affected because a 5h reset mints a new `five_hour_window_key` that naturally scopes its `MAX`. The clamp now mirrors the CLI/dashboard segmentation (`_apply_reset_events_to_subweeks`: the post-reset window's `start_ts` becomes `effective_reset_at_utc`) by flooring the `MAX` to snapshots captured at/after the latest reset effective within the current window — `... AND unixepoch(captured_at_utc) >= unixepoch(<floor>)`, with `unixepoch()` on both sides because reset rows carry mixed offset spellings (`+00:00` and legacy `+03:00`) while `captured_at_utc` uses `Z`, so a lexical comparison would misorder them (the same rule the 5h-block cross-reset flag follows). Weeks with no reset event take the unchanged legacy query, so the normal monotonic-within-window behavior is byte-identical. Regression: new `extensions-hwm-7d-post-reset` fixture in `bin/cctally-statusline-test` (33 scenarios) seeds a pre-reset peak + post-reset row sharing one `week_start_date` plus a `week_reset_events` row and asserts the rendered line reads `7d 2%`, not `7d 41%`.
|
|
20
|
+
|
|
8
21
|
## [1.21.0] - 2026-05-28
|
|
9
22
|
|
|
10
23
|
### Added
|
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
|
|
@@ -4953,17 +4954,56 @@ def _build_statusline_injections(warn_once):
|
|
|
4953
4954
|
pass
|
|
4954
4955
|
if seven_resets is not None:
|
|
4955
4956
|
try:
|
|
4956
|
-
# Seven-day window
|
|
4957
|
-
#
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
).
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4957
|
+
# Seven-day window bounds from the resets_at epoch:
|
|
4958
|
+
# week_end = reset; week_start = reset - 7 days. The
|
|
4959
|
+
# date form is the snapshot lookup key (week_start_date
|
|
4960
|
+
# is deliberately NOT re-anchored across a mid-week
|
|
4961
|
+
# reset — see _apply_reset_events_to_subweeks).
|
|
4962
|
+
week_end_dt = dt.datetime.fromtimestamp(
|
|
4963
|
+
int(seven_resets), tz=dt.timezone.utc,
|
|
4964
|
+
)
|
|
4965
|
+
week_start_dt = week_end_dt - dt.timedelta(days=7)
|
|
4966
|
+
week_start_date = week_start_dt.date().isoformat()
|
|
4967
|
+
# Reset-aware floor. An Anthropic mid-week reset / in-
|
|
4968
|
+
# place credit leaves the pre-reset peak snapshots in
|
|
4969
|
+
# this SAME week_start_date bucket (the boundary the
|
|
4970
|
+
# snapshots carry does not change). A naive bucket-wide
|
|
4971
|
+
# MAX(weekly_percent) would clamp the post-reset value
|
|
4972
|
+
# UP to that stale peak — the statusline would show the
|
|
4973
|
+
# pre-reset 7d %. Mirror the CLI/dashboard segmentation
|
|
4974
|
+
# (_apply_reset_events_to_subweeks: post-reset window
|
|
4975
|
+
# start_ts := effective_reset_at_utc) by flooring the
|
|
4976
|
+
# MAX to snapshots captured at/after the latest reset
|
|
4977
|
+
# effective WITHIN this window. unixepoch() on both
|
|
4978
|
+
# sides — reset rows carry mixed offset spellings
|
|
4979
|
+
# (+00:00 / +03:00) while captured_at_utc uses 'Z', so a
|
|
4980
|
+
# lexical compare would misorder them (same rule as the
|
|
4981
|
+
# 5h-block cross-reset flag).
|
|
4982
|
+
floor_row = conn.execute(
|
|
4983
|
+
"SELECT MAX(effective_reset_at_utc) "
|
|
4984
|
+
"FROM week_reset_events "
|
|
4985
|
+
"WHERE unixepoch(effective_reset_at_utc) >= unixepoch(?) "
|
|
4986
|
+
" AND unixepoch(effective_reset_at_utc) < unixepoch(?)",
|
|
4987
|
+
(week_start_dt.isoformat(), week_end_dt.isoformat()),
|
|
4966
4988
|
).fetchone()
|
|
4989
|
+
floor_iso = (
|
|
4990
|
+
floor_row[0] if floor_row and floor_row[0] else None
|
|
4991
|
+
)
|
|
4992
|
+
if floor_iso is not None:
|
|
4993
|
+
row = conn.execute(
|
|
4994
|
+
"SELECT MAX(weekly_percent) "
|
|
4995
|
+
"FROM weekly_usage_snapshots "
|
|
4996
|
+
"WHERE week_start_date = ? "
|
|
4997
|
+
" AND unixepoch(captured_at_utc) >= unixepoch(?)",
|
|
4998
|
+
(week_start_date, floor_iso),
|
|
4999
|
+
).fetchone()
|
|
5000
|
+
else:
|
|
5001
|
+
row = conn.execute(
|
|
5002
|
+
"SELECT MAX(weekly_percent) "
|
|
5003
|
+
"FROM weekly_usage_snapshots "
|
|
5004
|
+
"WHERE week_start_date = ?",
|
|
5005
|
+
(week_start_date,),
|
|
5006
|
+
).fetchone()
|
|
4967
5007
|
if row and row[0] is not None:
|
|
4968
5008
|
seven_hwm = float(row[0])
|
|
4969
5009
|
except Exception:
|
|
@@ -13069,6 +13109,27 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
13069
13109
|
)
|
|
13070
13110
|
uc.set_defaults(func=cmd_update_check_internal)
|
|
13071
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
|
+
|
|
13072
13133
|
# Python 3.14 leaks `==SUPPRESS==` for hidden subparsers in --help; strip
|
|
13073
13134
|
# the pseudo-action so the row disappears entirely. (The choice still
|
|
13074
13135
|
# appears in the `{...}` choices header — there's no clean way to hide
|
|
@@ -14882,6 +14943,33 @@ cmd_tui = _cctally_tui.cmd_tui
|
|
|
14882
14943
|
_tui_render_once = _cctally_tui._tui_render_once
|
|
14883
14944
|
|
|
14884
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
|
+
|
|
14885
14973
|
def main(argv: list[str] | None = None) -> int:
|
|
14886
14974
|
_migrate_legacy_data_dir()
|
|
14887
14975
|
parser = build_parser()
|
|
@@ -14955,11 +15043,21 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
14955
15043
|
does not stop these side effects. Doctor reads update state for
|
|
14956
15044
|
its report via the gather layer; it must not refresh that state
|
|
14957
15045
|
opportunistically. Users who want a fresh check have
|
|
14958
|
-
``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."""
|
|
14959
15055
|
if command == "setup" and getattr(args, "uninstall", False):
|
|
14960
15056
|
return
|
|
14961
15057
|
if command == "doctor":
|
|
14962
15058
|
return
|
|
15059
|
+
if command == "repair-symlinks":
|
|
15060
|
+
return
|
|
14963
15061
|
# Self-heal: reconcile current_version with the running binary's
|
|
14964
15062
|
# CHANGELOG. Cheap (one CHANGELOG read, write only on the first
|
|
14965
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": {
|