cctally 1.62.0 → 1.64.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 +10 -0
- package/README.md +7 -0
- package/bin/_cctally_config.py +94 -0
- package/bin/_cctally_core.py +29 -0
- package/bin/_cctally_doctor.py +24 -0
- package/bin/_cctally_parser.py +76 -0
- package/bin/_cctally_setup.py +22 -0
- package/bin/_cctally_statusline.py +9 -1
- package/bin/_cctally_telemetry.py +362 -0
- package/bin/_cctally_update.py +48 -9
- package/bin/_lib_doctor.py +32 -0
- package/bin/_lib_statusline.py +25 -13
- package/bin/cctally +98 -0
- package/bin/cctally-npm-postinstall.js +4 -0
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,16 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.64.0] - 2026-07-07
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- cctally now sends an anonymous, opt-out install-count beat at most once a day so the project can gauge how many people actually use it. It is deliberately minimal and private: the only thing transmitted is a one-way token that rotates every month (derived from a random local id that never leaves your machine and cannot be recovered from the token), plus the cctally version and a coarse operating-system family (macos/linux/windows/other) — no IP, username, file paths, project names, or usage data — and nothing leaves your machine for at least 24 hours after first run, so you always have a window to opt out first. Run `cctally telemetry` to see the current state, why it is on or off, and exactly what would be sent; `cctally telemetry off` disables it (`cctally telemetry on` re-enables), `cctally telemetry reset` rotates your local id, and `cctally doctor` shows a read-only telemetry line. It is also disabled by `cctally config set telemetry.enabled false`, `CCTALLY_DISABLE_TELEMETRY=1`, the `DO_NOT_TRACK` convention, and automatically in development checkouts. Full transparency page — token construction, retention, and an honest threat model — is in [docs/telemetry.md](docs/telemetry.md).
|
|
12
|
+
|
|
13
|
+
## [1.63.0] - 2026-07-07
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- `cctally statusline --usage-only` renders just the subscription usage chip (`5h X% · 7d Y%`), dropping the model, cost, burn-rate, context, and reset-countdown segments — for users who want a small quota indicator in their prompt without the cost telemetry. Persist it with `cctally config set statusline.usage_only true`; `--no-usage-only` forces the full statusline when the config key is on. Contributed by @nathanm4 (public PR #3).
|
|
17
|
+
|
|
8
18
|
## [1.62.0] - 2026-07-06
|
|
9
19
|
|
|
10
20
|
### Changed
|
package/README.md
CHANGED
|
@@ -154,6 +154,12 @@ If you also use OpenAI's Codex CLI, `cctally codex daily / monthly / session` ar
|
|
|
154
154
|
|
|
155
155
|
`cctally doctor` gives you a read-only health check - install, hooks, sign-in, database, data freshness, pricing, and safety - and the same status shows up in the dashboard. `cctally pricing-check` warns you when the built-in model pricing is getting stale, `cctally db` manages the local database, and `cctally update` keeps your install current. The local cache is always safe to delete or rebuild.
|
|
156
156
|
|
|
157
|
+
## Privacy & telemetry
|
|
158
|
+
|
|
159
|
+
cctally sends an anonymous, opt-out **install-count beat** — at most once a day — so the project can gauge how many people actually use it (npm and GitHub numbers are drowned in bots and mirrors). The entire payload is a one-way token that rotates every month (derived from a random local id that never leaves your machine and can't be recovered from the token), the cctally version, and a coarse OS family (`macos`/`linux`/`windows`/`other`). No identity, file paths, prompts, usage data, or stored IP — ever. Nothing leaves your machine for at least 24 hours after first run, so you always have a window to opt out first.
|
|
160
|
+
|
|
161
|
+
Turn it off any time with `cctally telemetry off` (or `cctally config set telemetry.enabled false`, `CCTALLY_DISABLE_TELEMETRY=1`, or the community-standard `DO_NOT_TRACK=1`); it's also off automatically in dev checkouts. Run `cctally telemetry` to see the current state and exactly what would be sent. The full transparency page — token construction, retention, and an honest threat model — is [docs/telemetry.md](docs/telemetry.md).
|
|
162
|
+
|
|
157
163
|
## A faster, local-first ccusage
|
|
158
164
|
|
|
159
165
|
`cctally` started as a local replacement for [`ccusage`](https://github.com/ryoppippi/ccusage) and stays compatible at the level of common flows: `cctally claude <cmd>` is a drop-in for `ccusage claude <cmd>` (and `cctally codex <cmd>` for `ccusage codex <cmd>`), with the flat forms (`cctally daily`, `cctally codex-daily`, …) kept as aliases. Paste your ccusage commands verbatim - then reach for the dashboard, forecast, trend, conversation viewer, and alerts that ccusage doesn't have.
|
|
@@ -176,6 +182,7 @@ It's also fast. Pricing is built in and computed in-process from a local SQLite
|
|
|
176
182
|
- [Configuration](docs/configuration.md): `config.json` shape and week-start rules.
|
|
177
183
|
- [Architecture](docs/architecture.md): data flow, caches, week boundaries.
|
|
178
184
|
- [Runtime data](docs/runtime-data.md): what lives in `~/.local/share/cctally/`.
|
|
185
|
+
- [Telemetry](docs/telemetry.md): the anonymous install-count beat, in full — and how to opt out.
|
|
179
186
|
- [Command reference](docs/commands/): one page per subcommand.
|
|
180
187
|
|
|
181
188
|
## License
|
package/bin/_cctally_config.py
CHANGED
|
@@ -309,6 +309,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
309
309
|
"statusline.visual_burn_rate",
|
|
310
310
|
"statusline.cost_source",
|
|
311
311
|
"statusline.cctally_extensions",
|
|
312
|
+
"statusline.usage_only",
|
|
312
313
|
"budget.weekly_usd",
|
|
313
314
|
"budget.alerts_enabled",
|
|
314
315
|
"budget.alert_thresholds",
|
|
@@ -317,6 +318,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
317
318
|
"budget.projects",
|
|
318
319
|
"budget.project_alerts_enabled",
|
|
319
320
|
"budget.codex",
|
|
321
|
+
"telemetry.enabled",
|
|
320
322
|
)
|
|
321
323
|
|
|
322
324
|
|
|
@@ -379,6 +381,25 @@ def _validate_statusline_cctally_extensions(value):
|
|
|
379
381
|
)
|
|
380
382
|
|
|
381
383
|
|
|
384
|
+
def _validate_statusline_usage_only(value):
|
|
385
|
+
"""Validate ``statusline.usage_only``.
|
|
386
|
+
|
|
387
|
+
Accepts booleans (preferred) or canonical truthy/falsy strings
|
|
388
|
+
(``true``/``false``/``yes``/``no``/``on``/``off``/``1``/``0``).
|
|
389
|
+
"""
|
|
390
|
+
if isinstance(value, bool):
|
|
391
|
+
return value
|
|
392
|
+
if isinstance(value, str):
|
|
393
|
+
lo = value.strip().lower()
|
|
394
|
+
if lo in ("true", "yes", "on", "1"):
|
|
395
|
+
return True
|
|
396
|
+
if lo in ("false", "no", "off", "0"):
|
|
397
|
+
return False
|
|
398
|
+
raise ValueError(
|
|
399
|
+
f"statusline.usage_only must be boolean (got {value!r})"
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
|
|
382
403
|
def cmd_config(args: argparse.Namespace) -> int:
|
|
383
404
|
"""Get/set/unset persisted user preferences in config.json.
|
|
384
405
|
|
|
@@ -520,6 +541,25 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
520
541
|
except ValueError:
|
|
521
542
|
return True
|
|
522
543
|
return True
|
|
544
|
+
if key == "telemetry.enabled":
|
|
545
|
+
# Boolean opt-OUT (anonymous install-count telemetry, spec 2026-07-07).
|
|
546
|
+
# Default TRUE — absence is ON. A hand-edited junk value surfaces the
|
|
547
|
+
# True default. Mirrors dashboard.live_tail exactly, under a top-level
|
|
548
|
+
# `telemetry` block instead of `dashboard`.
|
|
549
|
+
block = config.get("telemetry") if isinstance(config, dict) else None
|
|
550
|
+
if not isinstance(block, dict):
|
|
551
|
+
block = {}
|
|
552
|
+
stored = block.get("enabled")
|
|
553
|
+
if stored is None:
|
|
554
|
+
return True
|
|
555
|
+
if isinstance(stored, bool):
|
|
556
|
+
return stored
|
|
557
|
+
if isinstance(stored, str):
|
|
558
|
+
try:
|
|
559
|
+
return c._normalize_alerts_enabled_value(stored)
|
|
560
|
+
except ValueError:
|
|
561
|
+
return True
|
|
562
|
+
return True
|
|
523
563
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
524
564
|
# Defaults mirror `_is_update_check_due` (True / 24 hours).
|
|
525
565
|
# Hand-edited junk surfaces as the default — matches dashboard.bind.
|
|
@@ -544,6 +584,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
544
584
|
"statusline.visual_burn_rate",
|
|
545
585
|
"statusline.cost_source",
|
|
546
586
|
"statusline.cctally_extensions",
|
|
587
|
+
"statusline.usage_only",
|
|
547
588
|
):
|
|
548
589
|
sl_block = config.get("statusline") if isinstance(config, dict) else None
|
|
549
590
|
if not isinstance(sl_block, dict):
|
|
@@ -554,6 +595,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
554
595
|
"visual_burn_rate": "off",
|
|
555
596
|
"cost_source": "auto",
|
|
556
597
|
"cctally_extensions": True,
|
|
598
|
+
"usage_only": False,
|
|
557
599
|
}
|
|
558
600
|
if stored is None:
|
|
559
601
|
return defaults[inner]
|
|
@@ -561,6 +603,7 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
561
603
|
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
562
604
|
"cost_source": _validate_statusline_cost_source,
|
|
563
605
|
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
606
|
+
"usage_only": _validate_statusline_usage_only,
|
|
564
607
|
}[inner]
|
|
565
608
|
try:
|
|
566
609
|
return validator(stored)
|
|
@@ -983,16 +1026,52 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
983
1026
|
else:
|
|
984
1027
|
print(f"dashboard.live_tail={'true' if canonical else 'false'}")
|
|
985
1028
|
return 0
|
|
1029
|
+
if key == "telemetry.enabled":
|
|
1030
|
+
# Anonymous install-count telemetry opt-out (spec 2026-07-07). Mirror
|
|
1031
|
+
# dashboard.live_tail exactly: validate the bool first, then
|
|
1032
|
+
# read-modify-write under config_writer_lock with _load_config_unlocked
|
|
1033
|
+
# (load_config under the lock self-deadlocks). Preserves any sibling
|
|
1034
|
+
# telemetry.* keys. Re-message the shared normalizer's ValueError with
|
|
1035
|
+
# the actual key name.
|
|
1036
|
+
try:
|
|
1037
|
+
canonical = c._normalize_alerts_enabled_value(raw)
|
|
1038
|
+
except ValueError:
|
|
1039
|
+
print(
|
|
1040
|
+
f"cctally: invalid boolean value for telemetry.enabled: "
|
|
1041
|
+
f"{raw!r} (expected true|false|yes|no|1|0|on|off)",
|
|
1042
|
+
file=sys.stderr,
|
|
1043
|
+
)
|
|
1044
|
+
return 2
|
|
1045
|
+
with config_writer_lock():
|
|
1046
|
+
config = _load_config_unlocked()
|
|
1047
|
+
existing = config.get("telemetry")
|
|
1048
|
+
if existing is not None and not isinstance(existing, dict):
|
|
1049
|
+
print(
|
|
1050
|
+
"cctally: telemetry config error: telemetry must be an object",
|
|
1051
|
+
file=sys.stderr,
|
|
1052
|
+
)
|
|
1053
|
+
return 2
|
|
1054
|
+
block = dict(existing or {})
|
|
1055
|
+
block["enabled"] = canonical
|
|
1056
|
+
config["telemetry"] = block
|
|
1057
|
+
save_config(config)
|
|
1058
|
+
if getattr(args, "emit_json", False):
|
|
1059
|
+
print(json.dumps({"telemetry": {"enabled": canonical}}, indent=2))
|
|
1060
|
+
else:
|
|
1061
|
+
print(f"telemetry.enabled={'true' if canonical else 'false'}")
|
|
1062
|
+
return 0
|
|
986
1063
|
if key in (
|
|
987
1064
|
"statusline.visual_burn_rate",
|
|
988
1065
|
"statusline.cost_source",
|
|
989
1066
|
"statusline.cctally_extensions",
|
|
1067
|
+
"statusline.usage_only",
|
|
990
1068
|
):
|
|
991
1069
|
inner_key = key.split(".", 1)[1]
|
|
992
1070
|
validator = {
|
|
993
1071
|
"visual_burn_rate": _validate_statusline_visual_burn_rate,
|
|
994
1072
|
"cost_source": _validate_statusline_cost_source,
|
|
995
1073
|
"cctally_extensions": _validate_statusline_cctally_extensions,
|
|
1074
|
+
"usage_only": _validate_statusline_usage_only,
|
|
996
1075
|
}[inner_key]
|
|
997
1076
|
try:
|
|
998
1077
|
normalized = validator(raw)
|
|
@@ -1386,10 +1465,25 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
1386
1465
|
save_config(config)
|
|
1387
1466
|
# idempotent: silent on missing key
|
|
1388
1467
|
return 0
|
|
1468
|
+
if key == "telemetry.enabled":
|
|
1469
|
+
# Mirror the dashboard.live_tail unset branch: drop only the enabled
|
|
1470
|
+
# leaf; if the telemetry block ends up empty, drop the parent too.
|
|
1471
|
+
# Unsetting restores the True (opt-out) default at read time.
|
|
1472
|
+
with config_writer_lock():
|
|
1473
|
+
config = _load_config_unlocked()
|
|
1474
|
+
block = config.get("telemetry")
|
|
1475
|
+
if isinstance(block, dict) and "enabled" in block:
|
|
1476
|
+
del block["enabled"]
|
|
1477
|
+
if not block:
|
|
1478
|
+
config.pop("telemetry", None)
|
|
1479
|
+
save_config(config)
|
|
1480
|
+
# idempotent: silent on missing key
|
|
1481
|
+
return 0
|
|
1389
1482
|
if key in (
|
|
1390
1483
|
"statusline.visual_burn_rate",
|
|
1391
1484
|
"statusline.cost_source",
|
|
1392
1485
|
"statusline.cctally_extensions",
|
|
1486
|
+
"statusline.usage_only",
|
|
1393
1487
|
):
|
|
1394
1488
|
inner_key = key.split(".", 1)[1]
|
|
1395
1489
|
with config_writer_lock():
|
package/bin/_cctally_core.py
CHANGED
|
@@ -69,6 +69,8 @@ def _init_paths_from_env() -> None:
|
|
|
69
69
|
global UPDATE_LOCK_PATH, UPDATE_LOG_PATH, UPDATE_LOG_ROTATED_PATH
|
|
70
70
|
global UPDATE_CHECK_LAST_FETCH_PATH, CLAUDE_SETTINGS_PATH
|
|
71
71
|
global CLAUDE_PROJECTS_DIR
|
|
72
|
+
global TELEMETRY_INSTALL_ID_PATH, TELEMETRY_LAST_BEAT_PATH
|
|
73
|
+
global TELEMETRY_NOTICE_SHOWN_PATH, TELEMETRY_FIRST_SEEN_PATH
|
|
72
74
|
|
|
73
75
|
home = pathlib.Path.home()
|
|
74
76
|
|
|
@@ -124,6 +126,14 @@ def _init_paths_from_env() -> None:
|
|
|
124
126
|
UPDATE_LOG_ROTATED_PATH = APP_DIR / "update.log.1"
|
|
125
127
|
UPDATE_CHECK_LAST_FETCH_PATH = APP_DIR / "update-check.last-fetch"
|
|
126
128
|
|
|
129
|
+
# Anonymous install-count telemetry markers (see spec 2026-07-07).
|
|
130
|
+
# All four derive from APP_DIR and are re-bound here so a redirected
|
|
131
|
+
# APP_DIR (tests, dev-instance isolation) carries them along.
|
|
132
|
+
TELEMETRY_INSTALL_ID_PATH = APP_DIR / "install_id"
|
|
133
|
+
TELEMETRY_LAST_BEAT_PATH = APP_DIR / "telemetry.last-beat"
|
|
134
|
+
TELEMETRY_NOTICE_SHOWN_PATH = APP_DIR / "telemetry.notice-shown"
|
|
135
|
+
TELEMETRY_FIRST_SEEN_PATH = APP_DIR / "telemetry.first-seen"
|
|
136
|
+
|
|
127
137
|
CLAUDE_SETTINGS_PATH = home / ".claude" / "settings.json"
|
|
128
138
|
|
|
129
139
|
# Claude session JSONL root. Production path is `~/.claude/projects`;
|
|
@@ -189,6 +199,25 @@ def _real_prod_data_dir() -> pathlib.Path:
|
|
|
189
199
|
_init_paths_from_env()
|
|
190
200
|
|
|
191
201
|
|
|
202
|
+
# === Telemetry constants (non-path; see spec 2026-07-07) =============
|
|
203
|
+
#
|
|
204
|
+
# These are static (not APP_DIR-derived) so they live outside
|
|
205
|
+
# `_init_paths_from_env()`. The kernel `bin/_cctally_telemetry.py` reads
|
|
206
|
+
# them at call time via its `_core()` accessor.
|
|
207
|
+
#
|
|
208
|
+
# Public, non-secret domain-separation constant folded into the monthly
|
|
209
|
+
# rotating token (SHA-256, truncated to 32 hex). It only namespaces
|
|
210
|
+
# cctally's token from any other consumer of the same install_id — it is
|
|
211
|
+
# NOT a secret and leaking it discloses nothing about the install.
|
|
212
|
+
TELEMETRY_PEPPER = "cctally-install-count-v1"
|
|
213
|
+
# Default beat endpoint; overridable for tests via CCTALLY_TELEMETRY_ENDPOINT.
|
|
214
|
+
TELEMETRY_ENDPOINT_DEFAULT = "https://cctally-telemetry.cctally.workers.dev/beat"
|
|
215
|
+
# Send at most one beat per this many seconds (mtime-gated on the beat marker).
|
|
216
|
+
TELEMETRY_BEAT_THROTTLE_SECONDS = 24 * 3600
|
|
217
|
+
# Wait this long after first eligibility before sending the first beat.
|
|
218
|
+
TELEMETRY_FIRST_BEAT_GRACE_SECONDS = 24 * 3600
|
|
219
|
+
|
|
220
|
+
|
|
192
221
|
def _resolve_claude_projects_dirs() -> list[pathlib.Path]:
|
|
193
222
|
"""Return Claude Code projects dirs that exist on disk, env-aware.
|
|
194
223
|
|
package/bin/_cctally_doctor.py
CHANGED
|
@@ -416,6 +416,26 @@ def doctor_gather_state(
|
|
|
416
416
|
except (json.JSONDecodeError, OSError):
|
|
417
417
|
pass
|
|
418
418
|
|
|
419
|
+
# ── Telemetry (anonymous install-count, spec 2026-07-07) ─────────
|
|
420
|
+
# Resolve the opt-out state via the pure kernel predicate — it reads env
|
|
421
|
+
# + config + the dev-checkout fact and NEVER mints an install_id / touches
|
|
422
|
+
# any marker (read-only H1 invariant). Uses the same raw config read as the
|
|
423
|
+
# safety block so doctor never auto-creates config.json; a missing/corrupt
|
|
424
|
+
# config degrades to `{}` (env/dev precedence still resolves correctly).
|
|
425
|
+
telemetry_enabled = True
|
|
426
|
+
telemetry_reason = "enabled"
|
|
427
|
+
try:
|
|
428
|
+
raw_tele_cfg: dict = {}
|
|
429
|
+
if _cctally_core.CONFIG_PATH.exists():
|
|
430
|
+
loaded = json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
|
|
431
|
+
if isinstance(loaded, dict):
|
|
432
|
+
raw_tele_cfg = loaded
|
|
433
|
+
telemetry_enabled, telemetry_reason = c.resolve_telemetry_state(raw_tele_cfg)
|
|
434
|
+
except Exception:
|
|
435
|
+
# Fail-soft: any read/parse/resolution error degrades to the enabled
|
|
436
|
+
# default (the check renders OK regardless — it never FAILs/WARNs).
|
|
437
|
+
telemetry_enabled, telemetry_reason = (True, "enabled")
|
|
438
|
+
|
|
419
439
|
# config.json — RAW READ, never load_config(). load_config()
|
|
420
440
|
# auto-creates on first run AND silently falls back to defaults
|
|
421
441
|
# on corruption — both behaviors would hide diagnostic state
|
|
@@ -518,6 +538,10 @@ def doctor_gather_state(
|
|
|
518
538
|
is_dev_checkout=_cctally_core._is_dev_checkout(),
|
|
519
539
|
# Preview channel (CCTALLY_CHANNEL=preview): surfaced in install.mode.
|
|
520
540
|
channel=("preview" if _cctally_core.is_preview_channel() else "prod"),
|
|
541
|
+
# Anonymous install-count telemetry (spec 2026-07-07): read-only
|
|
542
|
+
# opt-out state, resolved above without minting an install_id.
|
|
543
|
+
telemetry_enabled=telemetry_enabled,
|
|
544
|
+
telemetry_reason=telemetry_reason,
|
|
521
545
|
# Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
|
|
522
546
|
pricing_coverage=pricing_coverage,
|
|
523
547
|
# Conversation-sessions rollup consistency (#217 S1 / U9).
|
package/bin/_cctally_parser.py
CHANGED
|
@@ -868,6 +868,21 @@ def _build_statusline_parser(subparsers, name, *, help_text, xref):
|
|
|
868
868
|
action="store_false",
|
|
869
869
|
help="Suppress cctally 5h%%/7d%% segment.",
|
|
870
870
|
)
|
|
871
|
+
p.add_argument(
|
|
872
|
+
"--usage-only",
|
|
873
|
+
dest="usage_only",
|
|
874
|
+
action="store_true",
|
|
875
|
+
default=None,
|
|
876
|
+
help="Render only subscription usage percentages, e.g. "
|
|
877
|
+
"`5h 36%% · 7d 35%%` (config key statusline.usage_only).",
|
|
878
|
+
)
|
|
879
|
+
p.add_argument(
|
|
880
|
+
"--no-usage-only",
|
|
881
|
+
dest="usage_only",
|
|
882
|
+
action="store_false",
|
|
883
|
+
help="Render the full statusline even when statusline.usage_only "
|
|
884
|
+
"is enabled in config.",
|
|
885
|
+
)
|
|
871
886
|
p.add_argument(
|
|
872
887
|
"--config",
|
|
873
888
|
dest="config",
|
|
@@ -2210,6 +2225,47 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2210
2225
|
cfg_unset.add_argument("key", help="Config key")
|
|
2211
2226
|
cfg_unset.set_defaults(func=c.cmd_config)
|
|
2212
2227
|
|
|
2228
|
+
# ---- telemetry (anonymous install-count opt-out, spec 2026-07-07) ----
|
|
2229
|
+
tele_p = sub.add_parser(
|
|
2230
|
+
"telemetry",
|
|
2231
|
+
help="Show or change anonymous install-count telemetry",
|
|
2232
|
+
formatter_class=CLIHelpFormatter,
|
|
2233
|
+
description=textwrap.dedent("""\
|
|
2234
|
+
Anonymous install-count telemetry: what it sends, and how to opt out.
|
|
2235
|
+
|
|
2236
|
+
cctally sends, at most once a day, a minimal beat: a one-way
|
|
2237
|
+
month-rotating token (never your install id), the client version,
|
|
2238
|
+
and a coarse OS family (macos/linux/windows/other). No IP, no
|
|
2239
|
+
username, no paths, no session content ever leaves the machine.
|
|
2240
|
+
|
|
2241
|
+
Actions:
|
|
2242
|
+
(none) Show the current state, resolved reason, what gets sent,
|
|
2243
|
+
and the token that would be used this month.
|
|
2244
|
+
on Enable telemetry (sets telemetry.enabled = true).
|
|
2245
|
+
off Disable telemetry (sets telemetry.enabled = false).
|
|
2246
|
+
reset Discard the local install id and mint a fresh one.
|
|
2247
|
+
|
|
2248
|
+
It is also disabled by CCTALLY_DISABLE_TELEMETRY=1, the DO_NOT_TRACK
|
|
2249
|
+
convention, and in dev checkouts.
|
|
2250
|
+
|
|
2251
|
+
Examples:
|
|
2252
|
+
cctally telemetry
|
|
2253
|
+
cctally telemetry --json
|
|
2254
|
+
cctally telemetry off
|
|
2255
|
+
cctally telemetry on
|
|
2256
|
+
cctally telemetry reset
|
|
2257
|
+
"""),
|
|
2258
|
+
)
|
|
2259
|
+
tele_p.add_argument(
|
|
2260
|
+
"action", nargs="?", choices=("on", "off", "reset"),
|
|
2261
|
+
help="on|off|reset (omit to show current status).",
|
|
2262
|
+
)
|
|
2263
|
+
tele_p.add_argument(
|
|
2264
|
+
"--json", dest="json", action="store_true",
|
|
2265
|
+
help="Emit the status as JSON (status output only).",
|
|
2266
|
+
)
|
|
2267
|
+
tele_p.set_defaults(func=c.cmd_telemetry)
|
|
2268
|
+
|
|
2213
2269
|
# ---- alerts (threshold-actions Task 6) ----
|
|
2214
2270
|
p_alerts = sub.add_parser(
|
|
2215
2271
|
"alerts",
|
|
@@ -2634,6 +2690,26 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2634
2690
|
)
|
|
2635
2691
|
uc.set_defaults(func=c.cmd_update_check_internal)
|
|
2636
2692
|
|
|
2693
|
+
# ---- _telemetry-beat (internal — hidden, detached anonymous install-count worker) ----
|
|
2694
|
+
tb = sub.add_parser(
|
|
2695
|
+
"_telemetry-beat",
|
|
2696
|
+
help=argparse.SUPPRESS,
|
|
2697
|
+
formatter_class=CLIHelpFormatter,
|
|
2698
|
+
description=textwrap.dedent(
|
|
2699
|
+
"""\
|
|
2700
|
+
Internal subcommand: detached anonymous install-count beat
|
|
2701
|
+
worker, spawned broad-but-throttled from
|
|
2702
|
+
`_post_command_update_hooks` (spec 2026-07-07). A dedicated
|
|
2703
|
+
worker, decoupled from `_update-check` — it touches only the
|
|
2704
|
+
telemetry markers, never update-check state. Honours every
|
|
2705
|
+
opt-out (CCTALLY_DISABLE_TELEMETRY / DO_NOT_TRACK / config /
|
|
2706
|
+
dev checkout) via `resolve_telemetry_state`. Always returns 0;
|
|
2707
|
+
failures are swallowed.
|
|
2708
|
+
"""
|
|
2709
|
+
),
|
|
2710
|
+
)
|
|
2711
|
+
tb.set_defaults(func=c.cmd_telemetry_beat_internal)
|
|
2712
|
+
|
|
2637
2713
|
# ---- repair-symlinks (internal — hidden; npm-postinstall self-heal, issue #114) ----
|
|
2638
2714
|
rs = sub.add_parser(
|
|
2639
2715
|
"repair-symlinks",
|
package/bin/_cctally_setup.py
CHANGED
|
@@ -2138,7 +2138,25 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2138
2138
|
out.append(" cctally tui # terminal dashboard")
|
|
2139
2139
|
out.append(" cctally setup --status # verify install state")
|
|
2140
2140
|
|
|
2141
|
+
# Install-time telemetry disclosure (spec 2026-07-07 §4). Text summary
|
|
2142
|
+
# only — the --json envelope carries a structured `telemetry` field
|
|
2143
|
+
# instead (never prose). Shown unconditionally as a factual disclosure of
|
|
2144
|
+
# the on-by-default, opt-out install-count beat; the opt-out command +
|
|
2145
|
+
# docs link are always surfaced so the fact is discoverable even when the
|
|
2146
|
+
# interactive first-run notice never fires (headless / statusline-only).
|
|
2147
|
+
out.append("")
|
|
2148
|
+
out.append("cctally counts anonymous active installs to gauge real usage.")
|
|
2149
|
+
out.append("What's sent: a rotating, un-linkable token + version + OS family.")
|
|
2150
|
+
out.append("No identity, no paths, no usage data, no IP stored. Auto-expires monthly.")
|
|
2151
|
+
out.append("Opt out anytime: cctally telemetry off (or CCTALLY_DISABLE_TELEMETRY=1)")
|
|
2152
|
+
out.append("How it works: https://github.com/omrikais/cctally/blob/main/docs/telemetry.md")
|
|
2153
|
+
|
|
2141
2154
|
if getattr(args, "json", False):
|
|
2155
|
+
# JSON-safe telemetry disclosure (spec 2026-07-07 §4): a structured
|
|
2156
|
+
# field, never prose. Resolved READ-ONLY via `resolve_telemetry_state`
|
|
2157
|
+
# (side-effect-free — mints no install_id, writes no config), so the
|
|
2158
|
+
# envelope reports the opt-out state without arming telemetry.
|
|
2159
|
+
tele_enabled, tele_reason = c.resolve_telemetry_state(c.load_config())
|
|
2142
2160
|
envelope = {
|
|
2143
2161
|
"schema_version": 1,
|
|
2144
2162
|
"mode": "install",
|
|
@@ -2174,6 +2192,10 @@ def _setup_install(args: argparse.Namespace) -> int:
|
|
|
2174
2192
|
"session_cache_rows": bootstrap_rows,
|
|
2175
2193
|
"oauth_status": bootstrap_oauth_status,
|
|
2176
2194
|
},
|
|
2195
|
+
"telemetry": {
|
|
2196
|
+
"enabled": tele_enabled,
|
|
2197
|
+
"reason": tele_reason,
|
|
2198
|
+
},
|
|
2177
2199
|
"warnings_count": warnings,
|
|
2178
2200
|
"exit_code": 0,
|
|
2179
2201
|
}
|
|
@@ -218,6 +218,14 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
218
218
|
)
|
|
219
219
|
ext_on = True
|
|
220
220
|
|
|
221
|
+
usage_only = _resolve(args.usage_only, "usage_only", False)
|
|
222
|
+
if not isinstance(usage_only, bool):
|
|
223
|
+
warn_once(
|
|
224
|
+
f"cctally statusline: invalid statusline.usage_only="
|
|
225
|
+
f"{usage_only!r}; using False"
|
|
226
|
+
)
|
|
227
|
+
usage_only = False
|
|
228
|
+
|
|
221
229
|
tz_name = _resolve_statusline_tz(getattr(args, "timezone", None), cfg, warn_once)
|
|
222
230
|
|
|
223
231
|
# Color: explicit CLI > NO_COLOR env > TTY detect.
|
|
@@ -232,6 +240,7 @@ def cmd_statusline(args: argparse.Namespace) -> int:
|
|
|
232
240
|
context_low_threshold=int(args.context_low_threshold),
|
|
233
241
|
context_medium_threshold=int(args.context_medium_threshold),
|
|
234
242
|
cctally_extensions=bool(ext_on),
|
|
243
|
+
usage_only=bool(usage_only),
|
|
235
244
|
color=bool(color),
|
|
236
245
|
display_tz_name=tz_name,
|
|
237
246
|
debug=bool(args.debug),
|
|
@@ -634,4 +643,3 @@ def _build_statusline_injections(warn_once):
|
|
|
634
643
|
warn_once=warn_once,
|
|
635
644
|
)
|
|
636
645
|
|
|
637
|
-
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Anonymous install-count telemetry kernel (see spec 2026-07-07).
|
|
2
|
+
|
|
3
|
+
Pure-ish: state resolution, token derivation, and payload construction are
|
|
4
|
+
side-effect-free; only the beat path mints an ``install_id`` / touches the
|
|
5
|
+
on-disk markers / makes a network call. Stdlib only.
|
|
6
|
+
|
|
7
|
+
Privacy posture (spec 2026-07-07):
|
|
8
|
+
- No IP, no username, no path, no session content ever leaves the machine.
|
|
9
|
+
- The only durable identifier is a random UUID ``install_id`` stored 0600
|
|
10
|
+
under APP_DIR; it is NEVER transmitted. What we send is a per-month
|
|
11
|
+
rotating token ``sha256(install_id:YYYY-MM:pepper)[:32]`` — one-way and
|
|
12
|
+
unlinkable across months, so the server can de-dupe within a month
|
|
13
|
+
without ever seeing the id.
|
|
14
|
+
- Fully opt-out: ``CCTALLY_DISABLE_TELEMETRY``, the ``DO_NOT_TRACK``
|
|
15
|
+
convention, dev checkouts, and a ``telemetry.enabled = false`` config
|
|
16
|
+
key each disable it (see ``resolve_telemetry_state``).
|
|
17
|
+
- Network errors are swallowed — telemetry never affects the user's UX.
|
|
18
|
+
|
|
19
|
+
Cross-module symbols (``_is_dev_checkout``, ``resolve_client_version``,
|
|
20
|
+
``resolve_os_family``, ``_release_read_latest_release_version``) are reached
|
|
21
|
+
through the call-time ``_cctally()`` accessor so tests' ``monkeypatch`` on the
|
|
22
|
+
``cctally`` module namespace propagates into these bodies. Path/constant
|
|
23
|
+
reads go through the ``_core()`` accessor so a redirected APP_DIR (tests,
|
|
24
|
+
dev-instance isolation) is honored.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import argparse
|
|
29
|
+
import datetime as _dt
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
import platform
|
|
34
|
+
import sys
|
|
35
|
+
import uuid
|
|
36
|
+
import urllib.request
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _cctally():
|
|
40
|
+
"""Resolve the current ``cctally`` module at call-time (spec §5.5)."""
|
|
41
|
+
return sys.modules["cctally"]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _core():
|
|
45
|
+
import _cctally_core
|
|
46
|
+
return _cctally_core
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _truthy_env(name: str) -> bool:
|
|
50
|
+
"""A ``1``/``true``/``yes``/any-non-empty env value is truthy; unset,
|
|
51
|
+
empty, ``0``, ``false``, ``no`` are falsey."""
|
|
52
|
+
v = os.environ.get(name)
|
|
53
|
+
return v is not None and v.strip().lower() not in ("", "0", "false", "no")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_telemetry_state(config: dict) -> tuple[bool, str]:
|
|
57
|
+
"""Return ``(enabled, reason)`` — side-effect-free.
|
|
58
|
+
|
|
59
|
+
Precedence (first match wins): env opt-out, DO_NOT_TRACK, dev checkout,
|
|
60
|
+
config opt-out, else enabled. Never mints or touches any file.
|
|
61
|
+
"""
|
|
62
|
+
c = _cctally()
|
|
63
|
+
if _truthy_env("CCTALLY_DISABLE_TELEMETRY"):
|
|
64
|
+
return (False, "env-disabled")
|
|
65
|
+
if _truthy_env("DO_NOT_TRACK"):
|
|
66
|
+
return (False, "do-not-track")
|
|
67
|
+
if c._is_dev_checkout():
|
|
68
|
+
return (False, "dev-checkout")
|
|
69
|
+
tele = (config or {}).get("telemetry") or {}
|
|
70
|
+
if tele.get("enabled") is False:
|
|
71
|
+
return (False, "config-disabled")
|
|
72
|
+
return (True, "enabled")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def current_period(now: _dt.datetime | None = None) -> str:
|
|
76
|
+
"""Current rotation period as ``"YYYY-MM"`` in UTC."""
|
|
77
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
78
|
+
return now.astimezone(_dt.timezone.utc).strftime("%Y-%m")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def telemetry_token(install_id: str, period: str) -> str:
|
|
82
|
+
"""One-way, month-rotating token: ``sha256(id:period:pepper)[:32]``.
|
|
83
|
+
|
|
84
|
+
Deterministic within a period, unlinkable across periods, and never
|
|
85
|
+
reveals ``install_id``."""
|
|
86
|
+
pepper = _core().TELEMETRY_PEPPER
|
|
87
|
+
raw = f"{install_id}:{period}:{pepper}".encode("utf-8")
|
|
88
|
+
return hashlib.sha256(raw).hexdigest()[:32]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def read_install_id() -> str | None:
|
|
92
|
+
"""Read the persisted install_id, or ``None`` if absent/empty. Never mints."""
|
|
93
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
94
|
+
try:
|
|
95
|
+
return p.read_text(encoding="utf-8").strip() or None
|
|
96
|
+
except OSError:
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ensure_install_id() -> str:
|
|
101
|
+
"""Return the install_id, minting a random UUID at 0600 if missing."""
|
|
102
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
103
|
+
existing = read_install_id()
|
|
104
|
+
if existing:
|
|
105
|
+
return existing
|
|
106
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
iid = str(uuid.uuid4())
|
|
108
|
+
# Atomically create at 0600 (O_EXCL) rather than write-then-chmod, so the
|
|
109
|
+
# id file is never briefly world-readable in the umask window. install_id
|
|
110
|
+
# is a non-secret, resettable UUID that never leaves the machine, but 0600
|
|
111
|
+
# matches the cache-db sidecar-hardening posture. On a create race the
|
|
112
|
+
# loser gets FileExistsError and reads the winner's value.
|
|
113
|
+
try:
|
|
114
|
+
fd = os.open(p, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
115
|
+
except FileExistsError:
|
|
116
|
+
return read_install_id() or iid
|
|
117
|
+
except OSError:
|
|
118
|
+
# Any other open failure: fall back to a best-effort plain write.
|
|
119
|
+
p.write_text(iid + "\n", encoding="utf-8")
|
|
120
|
+
return iid
|
|
121
|
+
try:
|
|
122
|
+
os.write(fd, (iid + "\n").encode("utf-8"))
|
|
123
|
+
finally:
|
|
124
|
+
os.close(fd)
|
|
125
|
+
return iid
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def reset_install_id() -> str:
|
|
129
|
+
"""Discard any existing install_id and mint a fresh one."""
|
|
130
|
+
p = _core().TELEMETRY_INSTALL_ID_PATH
|
|
131
|
+
try:
|
|
132
|
+
p.unlink()
|
|
133
|
+
except OSError:
|
|
134
|
+
pass
|
|
135
|
+
return ensure_install_id()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def resolve_client_version() -> str:
|
|
139
|
+
"""The stamped client semver, or ``"unknown"`` when unstamped."""
|
|
140
|
+
c = _cctally()
|
|
141
|
+
cur = c._release_read_latest_release_version()
|
|
142
|
+
return cur[0] if cur else "unknown"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_os_family() -> str:
|
|
146
|
+
"""Coarse OS family: ``macos`` | ``linux`` | ``windows`` | ``other``."""
|
|
147
|
+
s = platform.system().lower()
|
|
148
|
+
return {"darwin": "macos", "linux": "linux", "windows": "windows"}.get(s, "other")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def build_beat_payload(install_id: str, *, now: _dt.datetime | None = None) -> dict:
|
|
152
|
+
"""The minimal beat body: ``{t, v, os}`` — token, client version, OS family.
|
|
153
|
+
|
|
154
|
+
Version/OS are read through the ``cctally`` accessor so a test's
|
|
155
|
+
``monkeypatch.setattr(cctally, "resolve_client_version", ...)`` drives the
|
|
156
|
+
output (the re-export lives on the ``cctally`` module, not this kernel's
|
|
157
|
+
namespace)."""
|
|
158
|
+
c = _cctally()
|
|
159
|
+
return {
|
|
160
|
+
"t": telemetry_token(install_id, current_period(now)),
|
|
161
|
+
"v": c.resolve_client_version(),
|
|
162
|
+
"os": c.resolve_os_family(),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _marker_age_seconds(path, now: _dt.datetime | None = None) -> float | None:
|
|
167
|
+
"""Seconds since ``path``'s mtime, or ``None`` when the marker is absent."""
|
|
168
|
+
try:
|
|
169
|
+
mtime = path.stat().st_mtime
|
|
170
|
+
except OSError:
|
|
171
|
+
return None
|
|
172
|
+
now = now or _dt.datetime.now(_dt.timezone.utc)
|
|
173
|
+
return now.timestamp() - mtime
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def telemetry_beat_due(now=None) -> bool:
|
|
177
|
+
"""True when no beat has been *attempted* within the throttle window.
|
|
178
|
+
|
|
179
|
+
The last-beat marker records the last beat ATTEMPT (not just the last
|
|
180
|
+
successful send): ``do_telemetry_beat`` stamps it on every non-disabled
|
|
181
|
+
run — arm, grace, sent, or failed — so this predicate bounds the parent
|
|
182
|
+
spawn gate to at most one worker per window regardless of outcome (a
|
|
183
|
+
network outage / undeployed endpoint can't churn re-spawns)."""
|
|
184
|
+
age = _marker_age_seconds(_core().TELEMETRY_LAST_BEAT_PATH, now)
|
|
185
|
+
return age is None or age >= _core().TELEMETRY_BEAT_THROTTLE_SECONDS
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def first_beat_grace_elapsed(now=None) -> bool:
|
|
189
|
+
"""True once the first-seen marker is at least the grace window old."""
|
|
190
|
+
age = _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH, now)
|
|
191
|
+
return age is not None and age >= _core().TELEMETRY_FIRST_BEAT_GRACE_SECONDS
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _touch(path) -> None:
|
|
195
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
path.touch()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def mark_first_seen(now=None) -> None:
|
|
200
|
+
"""Arm the first-seen marker once; subsequent calls are no-ops so the
|
|
201
|
+
grace window is measured from genuine first eligibility."""
|
|
202
|
+
p = _core().TELEMETRY_FIRST_SEEN_PATH
|
|
203
|
+
if _marker_age_seconds(p) is None:
|
|
204
|
+
_touch(p)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def touch_last_beat(now=None) -> None:
|
|
208
|
+
"""Stamp the last-beat-attempt marker to now (resets the throttle window).
|
|
209
|
+
|
|
210
|
+
Called at the top of every non-disabled ``do_telemetry_beat`` run so the
|
|
211
|
+
marker tracks the last ATTEMPT, not just the last successful send."""
|
|
212
|
+
_touch(_core().TELEMETRY_LAST_BEAT_PATH)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def notice_already_shown() -> bool:
|
|
216
|
+
return _core().TELEMETRY_NOTICE_SHOWN_PATH.exists()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def mark_notice_shown() -> None:
|
|
220
|
+
_touch(_core().TELEMETRY_NOTICE_SHOWN_PATH)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _endpoint() -> str:
|
|
224
|
+
return os.environ.get("CCTALLY_TELEMETRY_ENDPOINT") or _core().TELEMETRY_ENDPOINT_DEFAULT
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def do_telemetry_beat(config: dict, *, now=None, endpoint=None) -> str:
|
|
228
|
+
"""Orchestrate arm -> grace -> beat. Returns a status string:
|
|
229
|
+
|
|
230
|
+
``disabled:<reason>`` (opt-out), ``armed`` (first eligibility recorded),
|
|
231
|
+
``grace`` (still inside the first-beat grace window), ``sent`` (POST
|
|
232
|
+
succeeded), or ``failed`` (network error, swallowed).
|
|
233
|
+
|
|
234
|
+
Throttling to at most one beat per window is enforced at the PARENT
|
|
235
|
+
spawn gate (``_post_command_update_hooks``), which only spawns this
|
|
236
|
+
worker when ``telemetry_beat_due()`` is True. To make that bound hold
|
|
237
|
+
regardless of outcome, the last-beat-attempt marker is stamped FIRST —
|
|
238
|
+
immediately after the opt-out check, before the arm/grace/send branches
|
|
239
|
+
(mirroring ``_do_update_check``'s touch-the-marker-first crash-safety
|
|
240
|
+
pattern). Without this, the marker was stamped only on a successful
|
|
241
|
+
send, so the parent gate stayed True through the entire 24h grace
|
|
242
|
+
window and through any sustained outage (e.g. the endpoint not yet
|
|
243
|
+
deployed) — re-spawning a fresh detached worker on EVERY command,
|
|
244
|
+
including hot paths (statusline/record-usage/hook-tick).
|
|
245
|
+
|
|
246
|
+
Only the network POST is wrapped, so this swallows connection / DNS /
|
|
247
|
+
HTTP / timeout errors from the beat send (returning ``failed``); it is
|
|
248
|
+
not a blanket "never raises" — a defect in the pure marker/id helpers
|
|
249
|
+
would still surface (and the ``_telemetry-beat`` worker wraps the whole
|
|
250
|
+
call in its own try/except as belt-and-suspenders)."""
|
|
251
|
+
enabled, reason = resolve_telemetry_state(config)
|
|
252
|
+
if not enabled:
|
|
253
|
+
return f"disabled:{reason}"
|
|
254
|
+
# Stamp the last-beat-attempt marker FIRST (crash-safe, outcome-
|
|
255
|
+
# independent) so the parent spawn gate is bounded to <=1 worker per
|
|
256
|
+
# throttle window whether this run arms, waits out grace, sends, or
|
|
257
|
+
# fails. The internal ``telemetry_beat_due`` re-check that used to sit
|
|
258
|
+
# below the grace branch is intentionally gone: the marker was just
|
|
259
|
+
# touched, so it would always read fresh here.
|
|
260
|
+
touch_last_beat(now)
|
|
261
|
+
# Arm on first eligibility; do NOT beat until the grace window elapses.
|
|
262
|
+
if _marker_age_seconds(_core().TELEMETRY_FIRST_SEEN_PATH) is None:
|
|
263
|
+
mark_first_seen(now)
|
|
264
|
+
ensure_install_id()
|
|
265
|
+
return "armed"
|
|
266
|
+
if not first_beat_grace_elapsed(now):
|
|
267
|
+
return "grace"
|
|
268
|
+
iid = ensure_install_id()
|
|
269
|
+
payload = build_beat_payload(iid, now=now)
|
|
270
|
+
try:
|
|
271
|
+
req = urllib.request.Request(
|
|
272
|
+
endpoint or _endpoint(),
|
|
273
|
+
data=json.dumps(payload).encode("utf-8"),
|
|
274
|
+
headers={
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
"User-Agent": f"cctally-telemetry/{payload['v']}",
|
|
277
|
+
},
|
|
278
|
+
method="POST",
|
|
279
|
+
)
|
|
280
|
+
with urllib.request.urlopen(req, timeout=3):
|
|
281
|
+
pass
|
|
282
|
+
# NOTE: the marker was already stamped at the top of this function
|
|
283
|
+
# (touch-first), so no second touch is needed on the success path.
|
|
284
|
+
return "sent"
|
|
285
|
+
except Exception:
|
|
286
|
+
return "failed" # swallow — telemetry never affects UX
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _config_set_ns(key: str, value: str) -> argparse.Namespace:
|
|
290
|
+
"""Synthesize the ``argparse.Namespace`` ``cmd_config``'s ``set`` path
|
|
291
|
+
consumes (``action``/``key``/``value``/``emit_json``). Routing ``on``/``off``
|
|
292
|
+
through the real config setter keeps a single validation + locking
|
|
293
|
+
chokepoint rather than re-implementing the read-modify-write here."""
|
|
294
|
+
return argparse.Namespace(action="set", key=key, value=value, emit_json=False)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_telemetry(args) -> int:
|
|
298
|
+
"""`cctally telemetry [on|off|reset]` + bare status (`--json`).
|
|
299
|
+
|
|
300
|
+
``on``/``off`` flip the ``telemetry.enabled`` config key through the real
|
|
301
|
+
``cmd_config`` setter (its bool validation + atomic write). ``reset`` mints
|
|
302
|
+
a fresh ``install_id``. The bare/status path is strictly READ-ONLY — it
|
|
303
|
+
resolves the opt-out state from a RAW guarded ``config.json`` read (NOT
|
|
304
|
+
``load_config()``, which would auto-create config.json on a fresh install)
|
|
305
|
+
and previews the month token WITHOUT ever minting an id (it calls
|
|
306
|
+
``read_install_id``, never ``ensure_install_id``). It writes nothing.
|
|
307
|
+
"""
|
|
308
|
+
c = _cctally()
|
|
309
|
+
action = getattr(args, "action", None)
|
|
310
|
+
if action == "off":
|
|
311
|
+
return c.cmd_config(_config_set_ns("telemetry.enabled", "false"))
|
|
312
|
+
if action == "on":
|
|
313
|
+
return c.cmd_config(_config_set_ns("telemetry.enabled", "true"))
|
|
314
|
+
if action == "reset":
|
|
315
|
+
reset_install_id()
|
|
316
|
+
print("telemetry: install id reset")
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
# bare / status — strictly read-only. Resolve the opt-out state from a
|
|
320
|
+
# RAW guarded config read (mirroring _cctally_doctor's telemetry gather),
|
|
321
|
+
# NOT load_config(): load_config() calls ensure_dirs() and auto-creates
|
|
322
|
+
# config.json on a fresh install, which would contradict the documented
|
|
323
|
+
# "strictly read-only" / "never mints an install_id, writes config, or
|
|
324
|
+
# sends a beat" contract (docs/telemetry.md, docs/commands/telemetry.md).
|
|
325
|
+
# A missing/corrupt config degrades to `{}` (env/dev precedence still
|
|
326
|
+
# resolves correctly).
|
|
327
|
+
raw_config: dict = {}
|
|
328
|
+
try:
|
|
329
|
+
cfg_path = _core().CONFIG_PATH
|
|
330
|
+
if cfg_path.exists():
|
|
331
|
+
loaded = json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
332
|
+
if isinstance(loaded, dict):
|
|
333
|
+
raw_config = loaded
|
|
334
|
+
except Exception:
|
|
335
|
+
raw_config = {}
|
|
336
|
+
enabled, reason = resolve_telemetry_state(raw_config)
|
|
337
|
+
iid = read_install_id()
|
|
338
|
+
period = current_period()
|
|
339
|
+
token = telemetry_token(iid, period) if iid else None
|
|
340
|
+
info = {
|
|
341
|
+
"enabled": enabled,
|
|
342
|
+
"reason": reason,
|
|
343
|
+
"version": c.resolve_client_version(),
|
|
344
|
+
"os": c.resolve_os_family(),
|
|
345
|
+
"period": period,
|
|
346
|
+
"token_preview": token,
|
|
347
|
+
"fields": ["token", "version", "os"],
|
|
348
|
+
}
|
|
349
|
+
if getattr(args, "json", False):
|
|
350
|
+
print(json.dumps(info))
|
|
351
|
+
return 0
|
|
352
|
+
print(f"telemetry: {'enabled' if enabled else 'disabled'} ({reason})")
|
|
353
|
+
print(
|
|
354
|
+
f" sends: rotating monthly token + version ({info['version']}) "
|
|
355
|
+
f"+ os ({info['os']})"
|
|
356
|
+
)
|
|
357
|
+
print(f" token this month: {token or '(not yet armed)'}")
|
|
358
|
+
print(
|
|
359
|
+
" opt out: cctally telemetry off | "
|
|
360
|
+
"CCTALLY_DISABLE_TELEMETRY=1 | DO_NOT_TRACK=1"
|
|
361
|
+
)
|
|
362
|
+
return 0
|
package/bin/_cctally_update.py
CHANGED
|
@@ -1118,18 +1118,21 @@ def _do_update_check() -> None:
|
|
|
1118
1118
|
c._save_update_state(state)
|
|
1119
1119
|
|
|
1120
1120
|
|
|
1121
|
-
def
|
|
1122
|
-
"""Fire-and-forget
|
|
1123
|
-
|
|
1124
|
-
Detached `subprocess.Popen` with `start_new_session=True` so a
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1121
|
+
def _spawn_detached(command: str) -> None:
|
|
1122
|
+
"""Fire-and-forget a hidden self-subcommand as a detached worker.
|
|
1123
|
+
|
|
1124
|
+
Detached `subprocess.Popen` with `start_new_session=True` so a parent
|
|
1125
|
+
exit (the user closes the shell) doesn't propagate SIGHUP to the child;
|
|
1126
|
+
stdin/stdout/stderr all `/dev/null` so the child can't pollute the
|
|
1127
|
+
parent's terminal; exceptions swallowed so a failed spawn can't break
|
|
1128
|
+
the parent command. Shared launch boilerplate for both dedicated
|
|
1129
|
+
background workers below — `_update-check` and `_telemetry-beat` stay
|
|
1130
|
+
deliberately separate spawns (spec review finding #1) and share only
|
|
1131
|
+
this Popen shape.
|
|
1129
1132
|
"""
|
|
1130
1133
|
try:
|
|
1131
1134
|
subprocess.Popen(
|
|
1132
|
-
[sys.executable, os.path.realpath(sys.argv[0]),
|
|
1135
|
+
[sys.executable, os.path.realpath(sys.argv[0]), command],
|
|
1133
1136
|
stdin=subprocess.DEVNULL,
|
|
1134
1137
|
stdout=subprocess.DEVNULL,
|
|
1135
1138
|
stderr=subprocess.DEVNULL,
|
|
@@ -1141,6 +1144,11 @@ def _spawn_background_update_check() -> None:
|
|
|
1141
1144
|
pass
|
|
1142
1145
|
|
|
1143
1146
|
|
|
1147
|
+
def _spawn_background_update_check() -> None:
|
|
1148
|
+
"""Fire-and-forget the hidden ``_update-check`` worker (spec §3.6)."""
|
|
1149
|
+
_spawn_detached("_update-check")
|
|
1150
|
+
|
|
1151
|
+
|
|
1144
1152
|
def cmd_update_check_internal(args) -> int:
|
|
1145
1153
|
"""Hidden ``_update-check`` subcommand handler (spec §3.6).
|
|
1146
1154
|
|
|
@@ -1167,6 +1175,37 @@ def cmd_update_check_internal(args) -> int:
|
|
|
1167
1175
|
return 0
|
|
1168
1176
|
|
|
1169
1177
|
|
|
1178
|
+
def _spawn_background_telemetry_beat() -> None:
|
|
1179
|
+
"""Fire-and-forget the hidden ``_telemetry-beat`` worker.
|
|
1180
|
+
|
|
1181
|
+
A DEDICATED detached worker, deliberately separate from
|
|
1182
|
+
``_update-check`` (spec review finding #1): the anonymous install-count
|
|
1183
|
+
beat must never share a process, throttle marker, or failure mode with
|
|
1184
|
+
the update check. Only the launch boilerplate is shared, via
|
|
1185
|
+
``_spawn_detached``."""
|
|
1186
|
+
_spawn_detached("_telemetry-beat")
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def cmd_telemetry_beat_internal(args) -> int:
|
|
1190
|
+
"""Hidden ``_telemetry-beat`` subcommand handler.
|
|
1191
|
+
|
|
1192
|
+
The detached anonymous-install-count worker. It does the beat and
|
|
1193
|
+
NOTHING else — critically it must NOT read or write any update-check
|
|
1194
|
+
state (spec review finding #1: a dedicated worker fully decoupled
|
|
1195
|
+
from ``_update-check``). Always returns 0 so the parent's
|
|
1196
|
+
spawn-and-forget contract holds. The try/except wraps both the
|
|
1197
|
+
``load_config`` read and ``do_telemetry_beat`` (which only guarantees
|
|
1198
|
+
the network POST is swallowed, not that pure helper defects can't
|
|
1199
|
+
raise), and its return value is unused."""
|
|
1200
|
+
c = _cctally()
|
|
1201
|
+
try:
|
|
1202
|
+
config = c.load_config()
|
|
1203
|
+
c.do_telemetry_beat(config)
|
|
1204
|
+
except Exception:
|
|
1205
|
+
pass
|
|
1206
|
+
return 0
|
|
1207
|
+
|
|
1208
|
+
|
|
1170
1209
|
# === User-facing `cctally update` (spec §4) ===
|
|
1171
1210
|
# `cmd_update` routes by mode flag. Mode flags are mutually exclusive
|
|
1172
1211
|
# (argparse enforces it; the dispatcher's redundant check is defense in
|
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_statusline.py
CHANGED
|
@@ -54,6 +54,7 @@ class StatuslineArgs:
|
|
|
54
54
|
context_low_threshold: int
|
|
55
55
|
context_medium_threshold: int
|
|
56
56
|
cctally_extensions: bool
|
|
57
|
+
usage_only: bool
|
|
57
58
|
color: bool # ANSI on/off after auto-detect resolved
|
|
58
59
|
display_tz_name: str # IANA name; resolved upstream via
|
|
59
60
|
# get_display_tz_pref(cfg) — defaults to
|
|
@@ -372,6 +373,8 @@ def resolve_cctally_extensions(
|
|
|
372
373
|
inp: StatuslineInput,
|
|
373
374
|
now: datetime,
|
|
374
375
|
inj: StatuslineInjections,
|
|
376
|
+
*,
|
|
377
|
+
include_countdowns: bool = True,
|
|
375
378
|
) -> Optional[str]:
|
|
376
379
|
"""Segment 5 — cctally-only `5h X% (...) · 7d Y% (...)`.
|
|
377
380
|
|
|
@@ -411,12 +414,12 @@ def resolve_cctally_extensions(
|
|
|
411
414
|
parts = []
|
|
412
415
|
if five_pct is not None:
|
|
413
416
|
s = f"5h {int(round(five_pct))}%"
|
|
414
|
-
if five_resets is not None:
|
|
417
|
+
if include_countdowns and five_resets is not None:
|
|
415
418
|
s += f" ({_fmt_countdown(five_resets - now_epoch)})"
|
|
416
419
|
parts.append(s)
|
|
417
420
|
if seven_pct is not None:
|
|
418
421
|
s = f"7d {int(round(seven_pct))}%"
|
|
419
|
-
if seven_resets is not None:
|
|
422
|
+
if include_countdowns and seven_resets is not None:
|
|
420
423
|
s += f" ({_fmt_countdown(seven_resets - now_epoch)})"
|
|
421
424
|
parts.append(s)
|
|
422
425
|
return " · ".join(parts)
|
|
@@ -443,6 +446,19 @@ def _wrap_color(text: str, color: Optional[str], enable: bool) -> str:
|
|
|
443
446
|
_PERCENT_INT_RE = re.compile(r"(\d+)%")
|
|
444
447
|
|
|
445
448
|
|
|
449
|
+
def _colorize_usage_segment(ext: str, args: StatuslineArgs) -> str:
|
|
450
|
+
"""Color a 5h/7d usage segment using the cctally percent bands."""
|
|
451
|
+
nums = [int(x) for x in _PERCENT_INT_RE.findall(ext)]
|
|
452
|
+
mx = max(nums) if nums else 0
|
|
453
|
+
if mx < 60:
|
|
454
|
+
color = "green"
|
|
455
|
+
elif mx < 85:
|
|
456
|
+
color = "yellow"
|
|
457
|
+
else:
|
|
458
|
+
color = "red"
|
|
459
|
+
return _wrap_color(ext, color, args.color)
|
|
460
|
+
|
|
461
|
+
|
|
446
462
|
def render_statusline(
|
|
447
463
|
inp: StatuslineInput,
|
|
448
464
|
args: StatuslineArgs,
|
|
@@ -453,6 +469,12 @@ def render_statusline(
|
|
|
453
469
|
None segments (currently only segment 5). See spec §1 for the exact
|
|
454
470
|
layout and §3 for the data flow.
|
|
455
471
|
"""
|
|
472
|
+
if args.usage_only:
|
|
473
|
+
ext = resolve_cctally_extensions(
|
|
474
|
+
inp, now, inj, include_countdowns=False
|
|
475
|
+
)
|
|
476
|
+
return "" if ext is None else _colorize_usage_segment(ext, args)
|
|
477
|
+
|
|
456
478
|
seg1 = resolve_model_segment(inp)
|
|
457
479
|
|
|
458
480
|
# Segment 2: 💰 ... session / ... today / ... block (Xh Ym left)
|
|
@@ -486,17 +508,7 @@ def render_statusline(
|
|
|
486
508
|
if args.cctally_extensions:
|
|
487
509
|
ext = resolve_cctally_extensions(inp, now, inj)
|
|
488
510
|
if ext is not None:
|
|
489
|
-
|
|
490
|
-
# <60 green, <85 yellow, >=85 red.
|
|
491
|
-
nums = [int(x) for x in _PERCENT_INT_RE.findall(ext)]
|
|
492
|
-
mx = max(nums) if nums else 0
|
|
493
|
-
if mx < 60:
|
|
494
|
-
color = "green"
|
|
495
|
-
elif mx < 85:
|
|
496
|
-
color = "yellow"
|
|
497
|
-
else:
|
|
498
|
-
color = "red"
|
|
499
|
-
seg5 = _wrap_color(ext, color, args.color)
|
|
511
|
+
seg5 = _colorize_usage_segment(ext, args)
|
|
500
512
|
|
|
501
513
|
segs = [seg1, seg2, seg3, seg4]
|
|
502
514
|
if seg5 is not None:
|
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
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cctally",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.0",
|
|
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": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"bin/_cctally_share.py",
|
|
42
42
|
"bin/_cctally_statusline.py",
|
|
43
43
|
"bin/_cctally_sync_week.py",
|
|
44
|
+
"bin/_cctally_telemetry.py",
|
|
44
45
|
"bin/_cctally_tui.py",
|
|
45
46
|
"bin/_cctally_update.py",
|
|
46
47
|
"bin/_cctally_weekrefs.py",
|