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 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
@@ -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():
@@ -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
 
@@ -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).
@@ -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",
@@ -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
@@ -1118,18 +1118,21 @@ def _do_update_check() -> None:
1118
1118
  c._save_update_state(state)
1119
1119
 
1120
1120
 
1121
- def _spawn_background_update_check() -> None:
1122
- """Fire-and-forget the hidden `_update-check` worker.
1123
-
1124
- Detached `subprocess.Popen` with `start_new_session=True` so a
1125
- parent exit (the user closes the shell) doesn't propagate SIGHUP
1126
- to the child. stdin/stdout/stderr are all `/dev/null` so the child
1127
- can't accidentally pollute the parent's terminal. Exceptions are
1128
- swallowed: a failed spawn must not break the parent command.
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]), "_update-check"],
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
@@ -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
 
@@ -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
- # Color by the higher of (5h%, 7d%) using cctally bands:
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.62.0",
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",