cctally 1.63.0 → 1.65.0
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 +22 -0
- package/README.md +7 -0
- package/bin/_cctally_cache.py +60 -19
- package/bin/_cctally_config.py +68 -0
- package/bin/_cctally_core.py +29 -0
- package/bin/_cctally_dashboard.py +364 -38
- package/bin/_cctally_doctor.py +24 -0
- package/bin/_cctally_parser.py +61 -0
- package/bin/_cctally_setup.py +22 -0
- package/bin/_cctally_telemetry.py +362 -0
- package/bin/_cctally_tui.py +445 -236
- package/bin/_cctally_update.py +53 -9
- package/bin/_lib_conversation_query.py +349 -36
- package/bin/_lib_doctor.py +32 -0
- package/bin/_lib_perf.py +180 -0
- package/bin/_lib_transcript_access.py +23 -0
- package/bin/cctally +98 -0
- package/bin/cctally-npm-postinstall.js +4 -0
- package/dashboard/static/assets/index-CJTUCpKt.js +80 -0
- package/dashboard/static/assets/index-DQWNrIqu.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-0jzYm75p.css +0 -1
- package/dashboard/static/assets/index-D_Ylyqsf.js +0 -80
package/bin/_lib_doctor.py
CHANGED
|
@@ -169,6 +169,14 @@ class DoctorState:
|
|
|
169
169
|
# from the env; surfaced in the install.mode check. Defaulted (placed last)
|
|
170
170
|
# so existing constructors stay valid and the prod path is unchanged.
|
|
171
171
|
channel: str = "prod"
|
|
172
|
+
# Anonymous install-count telemetry (spec 2026-07-07): the resolved opt-out
|
|
173
|
+
# state + precedence reason from `resolve_telemetry_state`, computed by
|
|
174
|
+
# `doctor_gather_state` WITHOUT minting an install_id (read-only H1). Drives
|
|
175
|
+
# the always-OK `telemetry.state` check — a diagnostic surface, never a
|
|
176
|
+
# health failure. Defaulted (placed last) so existing constructors stay
|
|
177
|
+
# valid and default to the enabled (opt-out) posture.
|
|
178
|
+
telemetry_enabled: bool = True
|
|
179
|
+
telemetry_reason: str = "enabled"
|
|
172
180
|
|
|
173
181
|
|
|
174
182
|
@dataclasses.dataclass(frozen=True)
|
|
@@ -1178,6 +1186,27 @@ def _check_safety_update_available(s: DoctorState) -> CheckResult:
|
|
|
1178
1186
|
)
|
|
1179
1187
|
|
|
1180
1188
|
|
|
1189
|
+
def _check_telemetry(s: DoctorState) -> CheckResult:
|
|
1190
|
+
"""Anonymous install-count telemetry opt-out state (spec 2026-07-07).
|
|
1191
|
+
|
|
1192
|
+
A read-only DIAGNOSTIC surface — "is telemetry on, and if off, why" — that
|
|
1193
|
+
is ALWAYS ``ok``, never WARN/FAIL. Being disabled (env kill switch,
|
|
1194
|
+
DO_NOT_TRACK, a dev checkout, or ``telemetry.enabled = false``) is a valid
|
|
1195
|
+
user choice, not a health problem, so this check must never change doctor's
|
|
1196
|
+
severity counts or exit code. The gather layer resolves `enabled`/`reason`
|
|
1197
|
+
via `resolve_telemetry_state` WITHOUT minting an install_id.
|
|
1198
|
+
"""
|
|
1199
|
+
enabled = bool(s.telemetry_enabled)
|
|
1200
|
+
reason = s.telemetry_reason
|
|
1201
|
+
return CheckResult(
|
|
1202
|
+
id="telemetry.state", title="State",
|
|
1203
|
+
severity="ok",
|
|
1204
|
+
summary=f"{'enabled' if enabled else 'disabled'} ({reason})",
|
|
1205
|
+
remediation=None,
|
|
1206
|
+
details={"enabled": enabled, "reason": reason},
|
|
1207
|
+
)
|
|
1208
|
+
|
|
1209
|
+
|
|
1181
1210
|
# Each entry is (category_id, category_title, ((check_id, evaluator_fn_name), ...)).
|
|
1182
1211
|
# The dotted check_id is the stable JSON-contract ID (spec §5.2) AND the
|
|
1183
1212
|
# fingerprint identity-slice key (spec §5.5). When an evaluator raises,
|
|
@@ -1226,6 +1255,9 @@ _CATEGORY_DEFINITIONS: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...]
|
|
|
1226
1255
|
("safety.update_suppress", "_check_safety_update_suppress"),
|
|
1227
1256
|
("safety.update_available", "_check_safety_update_available"),
|
|
1228
1257
|
)),
|
|
1258
|
+
("telemetry", "Telemetry", (
|
|
1259
|
+
("telemetry.state", "_check_telemetry"),
|
|
1260
|
+
)),
|
|
1229
1261
|
)
|
|
1230
1262
|
|
|
1231
1263
|
|
package/bin/_lib_perf.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Opt-in backend phase-instrumentation collector (issue #276, Session A).
|
|
2
|
+
|
|
3
|
+
Stdlib-only. A thread-local nested-phase timing collector, gated on the
|
|
4
|
+
CCTALLY_PERF_TRACE env var. Near-noop when off: phase() returns a shared
|
|
5
|
+
_NULL_PHASE singleton — no allocation, no perf_counter, no stack push.
|
|
6
|
+
|
|
7
|
+
Two renderers sit on the same phase tree:
|
|
8
|
+
* flush_stderr(root) — CLI indented outline (stdout stays byte-identical).
|
|
9
|
+
* stash_last(root) — the dashboard freezes the completed tree (to_dict)
|
|
10
|
+
into a process-global slot for the loopback
|
|
11
|
+
/api/debug/backend endpoint to read.
|
|
12
|
+
|
|
13
|
+
This surface is a diagnostic, NOT a consumer contract: phase names, nesting,
|
|
14
|
+
and fields may change without a version bump.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
_FALSEY = {"", "0", "false", "no", "off"}
|
|
24
|
+
_ENABLED = os.environ.get("CCTALLY_PERF_TRACE", "").strip().lower() not in _FALSEY
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def enabled() -> bool:
|
|
28
|
+
return _ENABLED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_enabled(value: bool) -> None:
|
|
32
|
+
"""Flip tracing at runtime (tests; not used by the dashboard, which reads
|
|
33
|
+
the env at import time)."""
|
|
34
|
+
global _ENABLED
|
|
35
|
+
_ENABLED = bool(value)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_tls = threading.local()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _stack():
|
|
42
|
+
s = getattr(_tls, "stack", None)
|
|
43
|
+
if s is None:
|
|
44
|
+
s = []
|
|
45
|
+
_tls.stack = s
|
|
46
|
+
return s
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Phase:
|
|
50
|
+
__slots__ = ("name", "elapsed_ms", "count", "meta", "children", "_start", "_stack")
|
|
51
|
+
|
|
52
|
+
def __init__(self, name, stack):
|
|
53
|
+
self.name = name
|
|
54
|
+
self.elapsed_ms = 0.0
|
|
55
|
+
self.count = None
|
|
56
|
+
self.meta = None
|
|
57
|
+
self.children = []
|
|
58
|
+
self._start = 0.0
|
|
59
|
+
self._stack = stack
|
|
60
|
+
|
|
61
|
+
def set_count(self, n):
|
|
62
|
+
self.count = int(n)
|
|
63
|
+
|
|
64
|
+
def set_meta(self, **kw):
|
|
65
|
+
if self.meta is None:
|
|
66
|
+
self.meta = {}
|
|
67
|
+
self.meta.update(kw)
|
|
68
|
+
|
|
69
|
+
def __enter__(self):
|
|
70
|
+
self._start = time.perf_counter()
|
|
71
|
+
self._stack.append(self)
|
|
72
|
+
return self
|
|
73
|
+
|
|
74
|
+
def __exit__(self, *exc):
|
|
75
|
+
self.elapsed_ms = (time.perf_counter() - self._start) * 1000.0
|
|
76
|
+
# Identity-aware unwind. If a nested phase leaked (its __exit__ was
|
|
77
|
+
# skipped — e.g. an exception escaped a manually CM-bracketed region),
|
|
78
|
+
# drop the leaked frames sitting above us so we never append a phase to
|
|
79
|
+
# its OWN children (which would make to_dict() self-recurse) and never
|
|
80
|
+
# strand a stack that hides the real root. Pop down to and including
|
|
81
|
+
# self; if self is not on the stack (double __exit__, or an ancestor
|
|
82
|
+
# already unwound us), do nothing.
|
|
83
|
+
stack = self._stack
|
|
84
|
+
if self not in stack:
|
|
85
|
+
return False
|
|
86
|
+
while stack and stack[-1] is not self:
|
|
87
|
+
stack.pop() # discard a leaked descendant frame
|
|
88
|
+
stack.pop() # pop self
|
|
89
|
+
if stack:
|
|
90
|
+
stack[-1].children.append(self)
|
|
91
|
+
else:
|
|
92
|
+
_tls.root = self # outermost phase closed -> the build root
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
def to_dict(self):
|
|
96
|
+
d = {"name": self.name, "elapsed_ms": round(self.elapsed_ms, 3)}
|
|
97
|
+
if self.count is not None:
|
|
98
|
+
d["count"] = self.count
|
|
99
|
+
if self.meta:
|
|
100
|
+
d["meta"] = dict(self.meta)
|
|
101
|
+
if self.children:
|
|
102
|
+
d["children"] = [c.to_dict() for c in self.children]
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _NullPhase:
|
|
107
|
+
"""Shared no-op returned when tracing is off. No allocation per phase()."""
|
|
108
|
+
|
|
109
|
+
def set_count(self, n):
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def set_meta(self, **kw):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def __enter__(self):
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *exc):
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_NULL_PHASE = _NullPhase()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def phase(name):
|
|
126
|
+
if not _ENABLED:
|
|
127
|
+
return _NULL_PHASE
|
|
128
|
+
return Phase(name, _stack())
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def current_root():
|
|
132
|
+
return getattr(_tls, "root", None)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def reset_thread():
|
|
136
|
+
_tls.stack = []
|
|
137
|
+
_tls.root = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def flush_stderr(root):
|
|
141
|
+
if root is None:
|
|
142
|
+
return
|
|
143
|
+
lines = []
|
|
144
|
+
|
|
145
|
+
def walk(p, depth):
|
|
146
|
+
indent = " " * depth
|
|
147
|
+
extra = ""
|
|
148
|
+
if p.count is not None:
|
|
149
|
+
extra += f" (count={p.count})"
|
|
150
|
+
if p.meta:
|
|
151
|
+
extra += " " + " ".join(f"{k}={v}" for k, v in p.meta.items())
|
|
152
|
+
lines.append(f"{indent}{p.name} {p.elapsed_ms:.1f}ms{extra}")
|
|
153
|
+
for c in p.children:
|
|
154
|
+
walk(c, depth + 1)
|
|
155
|
+
|
|
156
|
+
walk(root, 0)
|
|
157
|
+
sys.stderr.write("backend-perf:\n" + "\n".join(lines) + "\n")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── process-global last-completed-tree slot (dashboard -> endpoint) ──────────
|
|
161
|
+
# The writer freezes the tree with to_dict() then binds the module global in
|
|
162
|
+
# ONE statement; once bound the dict is never mutated (the next build binds a
|
|
163
|
+
# fresh dict). Assignment is atomic under the GIL, so the HTTP reader thread
|
|
164
|
+
# always sees a whole, immutable "last completed build".
|
|
165
|
+
_LAST_BACKEND_PERF = None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def stash_last(root, *, generation=None, generated_at=None):
|
|
169
|
+
global _LAST_BACKEND_PERF
|
|
170
|
+
if root is None:
|
|
171
|
+
return
|
|
172
|
+
_LAST_BACKEND_PERF = {
|
|
173
|
+
"generated_at": generated_at,
|
|
174
|
+
"generation": generation,
|
|
175
|
+
"phases": root.to_dict(),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def last_backend_perf():
|
|
180
|
+
return _LAST_BACKEND_PERF
|
|
@@ -55,6 +55,29 @@ def is_loopback(host: str) -> bool:
|
|
|
55
55
|
return False
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def debug_backend_allowed(peer_ip, host) -> bool:
|
|
59
|
+
"""Gate for the loopback-only ``/api/debug/backend`` diagnostic endpoint
|
|
60
|
+
(issue #276, Session A).
|
|
61
|
+
|
|
62
|
+
STRICTER than the transcript gate — it never opens to the LAN. Two checks:
|
|
63
|
+
|
|
64
|
+
* PRIMARY: the TCP peer (``client_address[0]``) must be a loopback
|
|
65
|
+
literal. This is the unspoofable signal — the dashboard can bind
|
|
66
|
+
``0.0.0.0`` (``dashboard.bind = lan``), and a ``Host``-header-only
|
|
67
|
+
check would let a LAN client connect to the LAN socket while sending
|
|
68
|
+
``Host: 127.0.0.1``. The peer address cannot be spoofed that way.
|
|
69
|
+
* DEFENSE-IN-DEPTH: the ``Host`` authority must ALSO be an IP-literal
|
|
70
|
+
loopback (anti-DNS-rebinding) — a hostname ``Host`` (a rebinding
|
|
71
|
+
vector) is rejected even from a loopback peer.
|
|
72
|
+
|
|
73
|
+
``dashboard.expose_transcripts`` is NEVER consulted — this surface is
|
|
74
|
+
loopback-only, ALWAYS. Fail closed on a missing/empty peer or Host.
|
|
75
|
+
"""
|
|
76
|
+
if not is_loopback(peer_ip):
|
|
77
|
+
return False
|
|
78
|
+
return is_loopback(authority_host(host))
|
|
79
|
+
|
|
80
|
+
|
|
58
81
|
def transcripts_allowed(bind_host, expose: bool) -> bool:
|
|
59
82
|
"""Are transcripts served AT ALL on this bind? Loopback bind always; a
|
|
60
83
|
non-loopback (LAN) bind only under the explicit ``expose`` opt-in."""
|
package/bin/cctally
CHANGED
|
@@ -214,6 +214,10 @@ make_week_ref = _cctally_core.make_week_ref
|
|
|
214
214
|
_get_latest_row_for_week = _cctally_core._get_latest_row_for_week
|
|
215
215
|
_reset_aware_floor = _cctally_core._reset_aware_floor
|
|
216
216
|
get_latest_usage_for_week = _cctally_core.get_latest_usage_for_week
|
|
217
|
+
# Re-exported so the telemetry kernel's ``c._is_dev_checkout()`` accessor
|
|
218
|
+
# call resolves on the ``cctally`` module (and tests can monkeypatch it
|
|
219
|
+
# there). The canonical definition lives in _cctally_core.
|
|
220
|
+
_is_dev_checkout = _cctally_core._is_dev_checkout
|
|
217
221
|
|
|
218
222
|
# === Path constants — re-exported from _cctally_core ================
|
|
219
223
|
#
|
|
@@ -982,6 +986,8 @@ _is_update_check_due = _cctally_update._is_update_check_due
|
|
|
982
986
|
_do_update_check = _cctally_update._do_update_check
|
|
983
987
|
_spawn_background_update_check = _cctally_update._spawn_background_update_check
|
|
984
988
|
cmd_update_check_internal = _cctally_update.cmd_update_check_internal
|
|
989
|
+
_spawn_background_telemetry_beat = _cctally_update._spawn_background_telemetry_beat
|
|
990
|
+
cmd_telemetry_beat_internal = _cctally_update.cmd_telemetry_beat_internal
|
|
985
991
|
SKIP_USE_STATE_LATEST = _cctally_update.SKIP_USE_STATE_LATEST
|
|
986
992
|
_format_update_command = _cctally_update._format_update_command
|
|
987
993
|
_prerelease_note = _cctally_update._prerelease_note
|
|
@@ -1009,6 +1015,36 @@ _should_show_update_banner = _cctally_update._should_show_update_banner
|
|
|
1009
1015
|
_format_update_banner = _cctally_update._format_update_banner
|
|
1010
1016
|
|
|
1011
1017
|
|
|
1018
|
+
# Anonymous install-count telemetry kernel (spec 2026-07-07). Eagerly
|
|
1019
|
+
# re-exported so tests reach the pure functions via the ``cctally``
|
|
1020
|
+
# namespace (mirroring the ``_check_npm_latest_version`` re-export above)
|
|
1021
|
+
# and so the kernel's moved bodies that call ``c.resolve_client_version``
|
|
1022
|
+
# / ``c.resolve_os_family`` / ``c._release_read_latest_release_version``
|
|
1023
|
+
# through the ``_cctally()`` accessor pick up any test monkeypatch on the
|
|
1024
|
+
# cctally module. Path constants (TELEMETRY_INSTALL_ID_PATH etc.) live on
|
|
1025
|
+
# _cctally_core and are read via ``_core()`` at call time; tests patch
|
|
1026
|
+
# them through ``monkeypatch.setattr(_cctally_core, "X", v)`` (conftest's
|
|
1027
|
+
# ``redirect_paths()`` covers the set).
|
|
1028
|
+
_cctally_telemetry = _load_sibling("_cctally_telemetry")
|
|
1029
|
+
resolve_telemetry_state = _cctally_telemetry.resolve_telemetry_state
|
|
1030
|
+
telemetry_token = _cctally_telemetry.telemetry_token
|
|
1031
|
+
current_period = _cctally_telemetry.current_period
|
|
1032
|
+
resolve_client_version = _cctally_telemetry.resolve_client_version
|
|
1033
|
+
resolve_os_family = _cctally_telemetry.resolve_os_family
|
|
1034
|
+
build_beat_payload = _cctally_telemetry.build_beat_payload
|
|
1035
|
+
read_install_id = _cctally_telemetry.read_install_id
|
|
1036
|
+
ensure_install_id = _cctally_telemetry.ensure_install_id
|
|
1037
|
+
reset_install_id = _cctally_telemetry.reset_install_id
|
|
1038
|
+
do_telemetry_beat = _cctally_telemetry.do_telemetry_beat
|
|
1039
|
+
telemetry_beat_due = _cctally_telemetry.telemetry_beat_due
|
|
1040
|
+
first_beat_grace_elapsed = _cctally_telemetry.first_beat_grace_elapsed
|
|
1041
|
+
mark_first_seen = _cctally_telemetry.mark_first_seen
|
|
1042
|
+
touch_last_beat = _cctally_telemetry.touch_last_beat
|
|
1043
|
+
notice_already_shown = _cctally_telemetry.notice_already_shown
|
|
1044
|
+
mark_notice_shown = _cctally_telemetry.mark_notice_shown
|
|
1045
|
+
cmd_telemetry = _cctally_telemetry.cmd_telemetry
|
|
1046
|
+
|
|
1047
|
+
|
|
1012
1048
|
# Phase F #22: ``bin/_cctally_dashboard.py`` is loaded eagerly per spec §4.8
|
|
1013
1049
|
# carve-out — ``tests/test_dashboard_*.py`` and ``tests/test_share_*.py``
|
|
1014
1050
|
# reach into ~25 dashboard symbols via ``ns["X"]`` direct-dict reads and
|
|
@@ -2853,6 +2889,20 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2853
2889
|
# (it runs before the wrapper sets CCTALLY_DATA_DIR, so APP_DIR is
|
|
2854
2890
|
# still cctally-dev at this point). Same rationale class as doctor.
|
|
2855
2891
|
return
|
|
2892
|
+
if command in ("_update-check", "_telemetry-beat"):
|
|
2893
|
+
# Both hidden workers are detached and have already done their one job
|
|
2894
|
+
# in their own command handler; neither must re-enter this hook.
|
|
2895
|
+
# Without the guard the ``_update-check`` worker would fall through to
|
|
2896
|
+
# the telemetry gate below and (throttle-bounded) spawn a
|
|
2897
|
+
# ``_telemetry-beat`` — a worker spawning another worker. And
|
|
2898
|
+
# ``do_telemetry_beat`` stamps its throttle marker FIRST on every
|
|
2899
|
+
# non-disabled run (arm/grace/sent/failed), so ``telemetry_beat_due()``
|
|
2900
|
+
# already reads False right after a worker run and the parent gate is
|
|
2901
|
+
# bounded to <=1 spawn/window regardless — this early-return is the
|
|
2902
|
+
# belt-and-suspenders that keeps the two dedicated workers symmetric
|
|
2903
|
+
# and fully hook-free (an internal worker never needs banners or
|
|
2904
|
+
# background spawns anyway).
|
|
2905
|
+
return
|
|
2856
2906
|
# Self-heal: reconcile current_version with the running binary's
|
|
2857
2907
|
# CHANGELOG. Cheap (one CHANGELOG read, write only on the first
|
|
2858
2908
|
# command after a manual upgrade). Runs before load_config so a
|
|
@@ -2865,6 +2915,54 @@ def _post_command_update_hooks(command: str | None, args) -> None:
|
|
|
2865
2915
|
sys.stderr.write(_format_update_banner(state) + "\n")
|
|
2866
2916
|
if _is_update_check_due(config):
|
|
2867
2917
|
_spawn_background_update_check()
|
|
2918
|
+
# Anonymous install-count telemetry (spec 2026-07-07). Broad-but-
|
|
2919
|
+
# throttled, placed after the same early-returns that guard the update
|
|
2920
|
+
# check so it inherits every side-effect skip (read-only/no-mutation
|
|
2921
|
+
# commands, the CCTALLY_DISABLE_UPDATE_CHECK test seam, and the
|
|
2922
|
+
# ``_telemetry-beat`` self-re-entry guard above). The env kill switches
|
|
2923
|
+
# (CCTALLY_DISABLE_TELEMETRY / DO_NOT_TRACK), the config opt-out, and
|
|
2924
|
+
# the dev-checkout guard all live inside ``resolve_telemetry_state`` —
|
|
2925
|
+
# do not re-check them here.
|
|
2926
|
+
enabled, _reason = resolve_telemetry_state(config)
|
|
2927
|
+
if enabled and telemetry_beat_due():
|
|
2928
|
+
_spawn_background_telemetry_beat()
|
|
2929
|
+
_maybe_print_telemetry_notice(command, config)
|
|
2930
|
+
|
|
2931
|
+
|
|
2932
|
+
def _maybe_print_telemetry_notice(command: str | None, config) -> None:
|
|
2933
|
+
"""One-time interactive notice that anonymous install-count telemetry
|
|
2934
|
+
is active (spec review finding #2). Banner-gated (NOT beat-gated): it
|
|
2935
|
+
prints once to an interactive stderr and, once marked, never again.
|
|
2936
|
+
Fully best-effort — the whole body is wrapped so any failure is
|
|
2937
|
+
swallowed and can't perturb the parent command.
|
|
2938
|
+
|
|
2939
|
+
Gates (all must hold to print):
|
|
2940
|
+
- telemetry enabled (opt-outs / config / dev-checkout suppress it);
|
|
2941
|
+
- not already shown (the ``TELEMETRY_NOTICE_SHOWN`` marker);
|
|
2942
|
+
- command not in ``_BANNER_SUPPRESSED_COMMANDS`` — the same quiet
|
|
2943
|
+
set the update banner honours (record-usage / hook-tick / blocks /
|
|
2944
|
+
etc.), so machine-consumed one-liners stay clean;
|
|
2945
|
+
- stderr is a real TTY — never pollute piped or redirected output.
|
|
2946
|
+
"""
|
|
2947
|
+
try:
|
|
2948
|
+
enabled, _reason = resolve_telemetry_state(config)
|
|
2949
|
+
if not enabled or notice_already_shown():
|
|
2950
|
+
return
|
|
2951
|
+
if command in _BANNER_SUPPRESSED_COMMANDS:
|
|
2952
|
+
return
|
|
2953
|
+
if not (sys.stderr and sys.stderr.isatty()):
|
|
2954
|
+
return
|
|
2955
|
+
docs = "https://github.com/omrikais/cctally/blob/main/docs/telemetry.md"
|
|
2956
|
+
sys.stderr.write(
|
|
2957
|
+
"cctally counts anonymous active installs to gauge real usage.\n"
|
|
2958
|
+
"What's sent: a rotating, un-linkable token + version + OS family.\n"
|
|
2959
|
+
"No identity, no paths, no usage data, no IP stored. Auto-expires monthly.\n"
|
|
2960
|
+
"Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)\n"
|
|
2961
|
+
f"How it works: {docs}\n"
|
|
2962
|
+
)
|
|
2963
|
+
mark_notice_shown()
|
|
2964
|
+
except Exception:
|
|
2965
|
+
pass
|
|
2868
2966
|
|
|
2869
2967
|
|
|
2870
2968
|
if __name__ == "__main__":
|
|
@@ -36,5 +36,9 @@ process.stdout.write(
|
|
|
36
36
|
' cctally setup\n' +
|
|
37
37
|
'\nThis installs additive Claude Code hooks (~/.claude/settings.json)\n' +
|
|
38
38
|
'and bootstraps the local SQLite cache (~/.local/share/cctally/).\n' +
|
|
39
|
+
'\ncctally counts anonymous active installs (a rotating monthly token +\n' +
|
|
40
|
+
'version + OS family; no identity, paths, or usage data). Opt out with\n' +
|
|
41
|
+
'`cctally telemetry off`, CCTALLY_DISABLE_TELEMETRY=1, or DO_NOT_TRACK=1.\n' +
|
|
42
|
+
'How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md\n' +
|
|
39
43
|
'\nDetails: https://github.com/omrikais/cctally#installation\n\n'
|
|
40
44
|
);
|