cctally 1.22.2 → 1.22.4

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.
@@ -0,0 +1,479 @@
1
+ """`cctally doctor` subcommand entry point.
2
+
3
+ I/O gather sibling: holds `doctor_gather_state` (reads install / hooks /
4
+ OAuth / DB / freshness / pricing / safety state) + `cmd_doctor` (thin
5
+ wrapper over the pure `_lib_doctor` kernel).
6
+
7
+ Honest *name* imports are KERNEL-ONLY (`_cctally_core`). `_lib_changelog`
8
+ is a qualified, eagerly-preloaded library kernel (bin/cctally:419) used
9
+ for `_lib_changelog._read_latest_changelog_version()`. **`_lib_doctor` is
10
+ imported CALL-TIME inside the functions (F1)** — NOT module-top — to
11
+ preserve the live lazy-load and avoid an unconditional ~1,239-line import
12
+ on every startup. Every other sibling-homed symbol (the whole `_setup_*`
13
+ family, `_db_status_for`, the update/refresh/config/pricing helpers, the
14
+ `_pricing_observed_models` seam) is reached via the call-time `_cctally()`
15
+ accessor so monkeypatches through `cctally`'s namespace are preserved —
16
+ see spec §3.1.
17
+
18
+ bin/cctally re-exports `cmd_doctor` AND `doctor_gather_state` (eager): the
19
+ parser resolves `c.cmd_doctor`, and the dashboard + tests reach
20
+ `sys.modules["cctally"].doctor_gather_state` (patchable binding).
21
+
22
+ Spec: docs/superpowers/specs/2026-05-30-extract-diagnostics-cmd-design.md
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import datetime as dt
28
+ import json
29
+ import pathlib
30
+ import shutil
31
+ import sqlite3
32
+ import sys
33
+
34
+ import _cctally_core
35
+ import _lib_changelog
36
+ from _cctally_core import _now_utc, eprint, now_utc_iso, parse_iso_datetime
37
+
38
+
39
+ def _cctally():
40
+ """Resolve the current `cctally` module at call-time (spec §3.1)."""
41
+ return sys.modules["cctally"]
42
+
43
+
44
+ def doctor_gather_state(
45
+ *,
46
+ now_utc: "dt.datetime | None" = None,
47
+ runtime_bind: "str | None" = None,
48
+ ):
49
+ """I/O chokepoint for `cctally doctor` (spec §7.2).
50
+
51
+ H1 invariant: config.json is read RAW (NOT via load_config), since
52
+ load_config auto-creates the file on first run — a read-only
53
+ diagnostic command must never mutate user state.
54
+ """
55
+ import _lib_doctor
56
+
57
+ c = _cctally()
58
+ if now_utc is None:
59
+ now_utc = _now_utc()
60
+
61
+ # ── Install ──────────────────────────────────────────────────────
62
+ repo_root = c._setup_resolve_repo_root()
63
+ dst_dir = c._setup_local_bin_dir()
64
+ try:
65
+ symlink_state = c._setup_compute_symlink_state(repo_root, dst_dir)
66
+ except Exception:
67
+ symlink_state = None
68
+ try:
69
+ path_includes = c._setup_path_includes_local_bin()
70
+ except Exception:
71
+ path_includes = None
72
+ # Issue #119: availability-aware install checks. Precomputed here (the
73
+ # I/O layer) so the kernel stays pure — `shutil.which` and the on-disk
74
+ # legacy-link probe never run in _lib_doctor.
75
+ # * cctally_reachable_on_path — channel-agnostic "is the command on
76
+ # $PATH at all?" (brew <prefix>/bin, npm prefix, source ~/.local/bin
77
+ # all satisfy it). Lets install.path pass without a ~/.local/bin
78
+ # membership check.
79
+ # * symlinks_path_pinned — true iff cctally runs ONLY through a legacy
80
+ # ~/.local/bin link to a retired/foreign install (live retired link
81
+ # with no reachable_elsewhere fallback). Mirrors the pinned-only-path
82
+ # predicate in _setup_install so doctor + setup agree on the fix.
83
+ try:
84
+ cctally_reachable_on_path = shutil.which("cctally") is not None
85
+ except Exception:
86
+ cctally_reachable_on_path = None
87
+ try:
88
+ symlinks_path_pinned = any(
89
+ s == "wrong"
90
+ and (dst_dir / n).is_symlink()
91
+ and c._setup_symlink_is_retired(dst_dir / n, n, repo_root)
92
+ and (dst_dir / n).resolve(strict=False).exists()
93
+ for n, s in (symlink_state or [])
94
+ )
95
+ except Exception:
96
+ symlinks_path_pinned = False
97
+ # install_is_brew — channel knowledge for the install.path WARN
98
+ # remediation. Brew kegs own no ~/.local/bin symlinks (#119), so the
99
+ # ~/.local/bin / `cctally setup` hint is wrong for them; the kernel
100
+ # can't derive this from repo_root (no I/O), so precompute it here.
101
+ try:
102
+ install_is_brew = c._setup_is_brew_install(repo_root)
103
+ except Exception:
104
+ install_is_brew = False
105
+ try:
106
+ legacy_snippet = c._setup_detect_legacy_snippet()
107
+ except Exception:
108
+ legacy_snippet = None
109
+
110
+ # ── Hooks ────────────────────────────────────────────────────────
111
+ try:
112
+ settings = c._load_claude_settings()
113
+ except c.SetupError:
114
+ settings = None
115
+ # Below: fail-soft posture for the diagnostic — any unexpected error
116
+ # in a sub-probe degrades that field to None rather than aborting the
117
+ # whole report.
118
+ try:
119
+ hook_counts = c._setup_count_hook_entries(settings or {})
120
+ except Exception:
121
+ hook_counts = None
122
+ try:
123
+ legacy_bespoke = c._setup_detect_legacy_bespoke_hooks(settings or {})
124
+ except Exception:
125
+ legacy_bespoke = None
126
+ try:
127
+ activity = c._setup_recent_log_stats()
128
+ except Exception:
129
+ activity = None
130
+
131
+ # ── Auth ─────────────────────────────────────────────────────────
132
+ try:
133
+ oauth_token_present = c._setup_oauth_token_present()
134
+ except OSError:
135
+ oauth_token_present = None
136
+
137
+ # ── DB ───────────────────────────────────────────────────────────
138
+ try:
139
+ stats_db_status = c._db_status_for(_cctally_core.DB_PATH, c._STATS_MIGRATIONS, "stats.db")
140
+ if not _cctally_core.DB_PATH.exists():
141
+ stats_db_status["_file_exists"] = False
142
+ except sqlite3.Error as exc:
143
+ stats_db_status = {"path": str(_cctally_core.DB_PATH), "user_version": 0,
144
+ "registry_size": len(c._STATS_MIGRATIONS),
145
+ "migrations": [], "_open_error": str(exc)}
146
+ try:
147
+ cache_db_status = c._db_status_for(_cctally_core.CACHE_DB_PATH, c._CACHE_MIGRATIONS, "cache.db")
148
+ if not _cctally_core.CACHE_DB_PATH.exists():
149
+ cache_db_status["_file_exists"] = False
150
+ except sqlite3.Error as exc:
151
+ cache_db_status = {"path": str(_cctally_core.CACHE_DB_PATH), "user_version": 0,
152
+ "registry_size": len(c._CACHE_MIGRATIONS),
153
+ "migrations": [], "_open_error": str(exc)}
154
+
155
+ # ── Data freshness ───────────────────────────────────────────────
156
+ latest_snapshot_at = None
157
+ forked_bucket_counts: dict | None = None
158
+ credited_weeks: list[dict] | None = None
159
+ try:
160
+ if _cctally_core.DB_PATH.exists():
161
+ conn = sqlite3.connect(str(_cctally_core.DB_PATH))
162
+ try:
163
+ try:
164
+ row = conn.execute(
165
+ "SELECT MAX(captured_at_utc) FROM weekly_usage_snapshots"
166
+ ).fetchone()
167
+ if row and row[0]:
168
+ latest_snapshot_at = parse_iso_datetime(
169
+ row[0], "weekly_usage_snapshots.captured_at_utc",
170
+ ).astimezone(dt.timezone.utc)
171
+ except sqlite3.OperationalError:
172
+ pass # table missing — treat as no snapshots yet
173
+ # Forked-bucket invariant probe. Each fork count is
174
+ # a raw SELECT against the already-open connection —
175
+ # no bonus open_db() recursion. Tables missing →
176
+ # count 0 (legacy DBs without one of these tables
177
+ # are intact by definition for that table).
178
+ forked_bucket_counts = {}
179
+ for table, key in (
180
+ ("weekly_usage_snapshots", "usage"),
181
+ ("weekly_cost_snapshots", "cost"),
182
+ ("percent_milestones", "milestones"),
183
+ ):
184
+ try:
185
+ row = conn.execute(
186
+ f"SELECT COUNT(*) FROM {table} "
187
+ f" WHERE week_start_at IS NOT NULL "
188
+ f" AND week_start_date != substr(week_start_at, 1, 10)"
189
+ ).fetchone()
190
+ forked_bucket_counts[key] = (
191
+ int(row[0]) if row and row[0] else 0
192
+ )
193
+ except sqlite3.OperationalError:
194
+ forked_bucket_counts[key] = 0
195
+ # v1.7.2 credited-week tracking. For each week with a
196
+ # past-effective ``week_reset_events`` row, gather the
197
+ # latest weekly_percent + count of post-credit milestones.
198
+ # The check warns when latest_percent >= 1.0 AND
199
+ # post_credit_milestone_count == 0.
200
+ # unixepoch() normalizes the cross-offset comparison.
201
+ try:
202
+ credit_rows = conn.execute(
203
+ """
204
+ SELECT wre.id AS event_id,
205
+ wre.new_week_end_at AS end_at,
206
+ wre.effective_reset_at_utc AS effective
207
+ FROM week_reset_events wre
208
+ WHERE unixepoch(wre.effective_reset_at_utc)
209
+ <= unixepoch(?)
210
+ """,
211
+ (now_utc_iso(),),
212
+ ).fetchall()
213
+ credited_weeks = []
214
+ for cr in credit_rows:
215
+ end_at = cr[1]
216
+ evt_id = cr[0]
217
+ latest = conn.execute(
218
+ """
219
+ SELECT week_start_date, weekly_percent
220
+ FROM weekly_usage_snapshots
221
+ WHERE week_end_at = ?
222
+ ORDER BY captured_at_utc DESC, id DESC
223
+ LIMIT 1
224
+ """,
225
+ (end_at,),
226
+ ).fetchone()
227
+ if latest is None or latest[0] is None:
228
+ continue
229
+ ws = latest[0]
230
+ lp = float(latest[1] or 0.0)
231
+ try:
232
+ mc_row = conn.execute(
233
+ "SELECT COUNT(*) FROM percent_milestones "
234
+ "WHERE week_start_date = ? AND reset_event_id = ?",
235
+ (ws, evt_id),
236
+ ).fetchone()
237
+ mc = int(mc_row[0]) if mc_row and mc_row[0] else 0
238
+ except sqlite3.OperationalError:
239
+ mc = 0
240
+ credited_weeks.append({
241
+ "week_start_date": ws,
242
+ "latest_weekly_percent": lp,
243
+ "post_credit_milestone_count": mc,
244
+ "event_id": evt_id,
245
+ })
246
+ except sqlite3.OperationalError:
247
+ # week_reset_events table missing — treat as no
248
+ # credited weeks (pre-feature DB).
249
+ credited_weeks = []
250
+ finally:
251
+ conn.close()
252
+ except Exception:
253
+ pass
254
+
255
+ cache_entries_count = None
256
+ cache_last_entry_at = None
257
+ try:
258
+ if _cctally_core.CACHE_DB_PATH.exists():
259
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
260
+ try:
261
+ row = conn.execute(
262
+ "SELECT COUNT(*), MAX(timestamp_utc) FROM session_entries"
263
+ ).fetchone()
264
+ if row:
265
+ cache_entries_count = int(row[0]) if row[0] is not None else 0
266
+ if row[1]:
267
+ cache_last_entry_at = parse_iso_datetime(
268
+ row[1], "session_entries.timestamp_utc",
269
+ ).astimezone(dt.timezone.utc)
270
+ except sqlite3.OperationalError:
271
+ pass # table missing — treat as zero
272
+ finally:
273
+ conn.close()
274
+ except Exception:
275
+ pass
276
+
277
+ claude_jsonl_present = False
278
+ try:
279
+ claude_dir = pathlib.Path.home() / ".claude" / "projects"
280
+ if claude_dir.exists():
281
+ claude_jsonl_present = next(claude_dir.glob("**/*.jsonl"), None) is not None
282
+ except Exception:
283
+ pass
284
+
285
+ codex_entries_count = None
286
+ codex_last_entry_at = None
287
+ try:
288
+ if _cctally_core.CACHE_DB_PATH.exists():
289
+ conn = sqlite3.connect(str(_cctally_core.CACHE_DB_PATH))
290
+ try:
291
+ row = conn.execute(
292
+ "SELECT COUNT(*), MAX(timestamp_utc) FROM codex_session_entries"
293
+ ).fetchone()
294
+ if row:
295
+ codex_entries_count = int(row[0]) if row[0] is not None else 0
296
+ if row[1]:
297
+ codex_last_entry_at = parse_iso_datetime(
298
+ row[1], "codex_session_entries.timestamp_utc",
299
+ ).astimezone(dt.timezone.utc)
300
+ except sqlite3.OperationalError:
301
+ pass
302
+ finally:
303
+ conn.close()
304
+ except Exception:
305
+ pass
306
+
307
+ # Issue #109: probe every $CODEX_HOME session root (not the single
308
+ # hardcoded ~/.codex/sessions), matching the multi-root ingestion path
309
+ # from #108. _codex_session_roots() already applies the sessions/-subdir
310
+ # rule and filters to existing dirs, so a bare glob per root suffices.
311
+ codex_jsonl_present = False
312
+ try:
313
+ for codex_dir in c._codex_session_roots():
314
+ if next(codex_dir.glob("**/*.jsonl"), None) is not None:
315
+ codex_jsonl_present = True
316
+ break
317
+ except Exception:
318
+ pass
319
+
320
+ # ── Safety ───────────────────────────────────────────────────────
321
+ # `dashboard.bind` is read via the same chokepoint that powers
322
+ # `cctally config get dashboard.bind` — `_config_known_value`
323
+ # normalizes hand-edited junk back to "loopback", matching the
324
+ # value cmd_dashboard would actually bind to.
325
+ #
326
+ # Raw JSON read (NOT load_config or _load_config_unlocked): both
327
+ # call `ensure_dirs()`, which creates `~/.local/share/cctally/`
328
+ # and `logs/` on a fresh HOME. Doctor is a read-only diagnostic
329
+ # (H1 invariant) — it must never mutate user state, even by
330
+ # creating an empty directory tree. Corrupt JSON yields
331
+ # `dashboard_bind_stored = "loopback"` (the same fallback the
332
+ # original try/except gave); the dedicated `config_json_valid`
333
+ # check surfaces the corruption separately.
334
+ dashboard_bind_stored = "loopback"
335
+ try:
336
+ if _cctally_core.CONFIG_PATH.exists():
337
+ raw_cfg = json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
338
+ if isinstance(raw_cfg, dict):
339
+ dashboard_bind_stored = (
340
+ c._config_known_value(raw_cfg, "dashboard.bind") or "loopback"
341
+ )
342
+ except (json.JSONDecodeError, OSError):
343
+ pass
344
+
345
+ # config.json — RAW READ, never load_config(). load_config()
346
+ # auto-creates on first run AND silently falls back to defaults
347
+ # on corruption — both behaviors would hide diagnostic state
348
+ # (codex H1).
349
+ config_json_error = None
350
+ try:
351
+ if _cctally_core.CONFIG_PATH.exists():
352
+ json.loads(_cctally_core.CONFIG_PATH.read_text(encoding="utf-8"))
353
+ except json.JSONDecodeError as exc:
354
+ config_json_error = f"{type(exc).__name__}: {exc}"
355
+ except OSError as exc:
356
+ config_json_error = f"OSError: {exc}"
357
+
358
+ update_state = None
359
+ update_state_error = None
360
+ try:
361
+ update_state = c._load_update_state()
362
+ except Exception as exc:
363
+ update_state_error = f"{type(exc).__name__}: {exc}"
364
+
365
+ update_suppress = None
366
+ update_suppress_error = None
367
+ try:
368
+ update_suppress = c._load_update_suppress()
369
+ except Exception as exc:
370
+ update_suppress_error = f"{type(exc).__name__}: {exc}"
371
+
372
+ # Same predicate the update banner uses; doctor must not warn about
373
+ # updates the user has already skipped or deferred.
374
+ effective_update_available, effective_update_reason = (
375
+ c._compute_effective_update_available(update_state, update_suppress, now_utc)
376
+ )
377
+
378
+ # ── Pricing coverage (spec §5.1) ─────────────────────────────────
379
+ # Read-only trailing-30d scan + classification via the pure-fn kernel.
380
+ # Any failure degrades to None so the check renders OK (never FAIL) and
381
+ # the rest of the report is unaffected — same posture as the cache reads
382
+ # above. `_pricing_observed_models` honors the no-mutation contract.
383
+ pricing_coverage = None
384
+ try:
385
+ observed = c._pricing_observed_models(now_utc)
386
+ # Detection-only: pass warn=False so finding an unpriced model here does
387
+ # NOT fire the cost-engine's `[cost] unknown model` stderr warning (this
388
+ # is a read-only diagnostic, and the warning would also poison the
389
+ # dedup set, suppressing a later genuine cost-path warning).
390
+ pricing_coverage = c.classify_coverage(
391
+ observed,
392
+ lambda m: c._resolve_model_pricing(m, warn=False),
393
+ c._is_codex_fallback,
394
+ )
395
+ except Exception:
396
+ pricing_coverage = None
397
+
398
+ # ── Meta ─────────────────────────────────────────────────────────
399
+ cctally_version_tuple = _lib_changelog._read_latest_changelog_version()
400
+ cctally_version = (
401
+ cctally_version_tuple[0] if cctally_version_tuple else "unknown"
402
+ )
403
+
404
+ return _lib_doctor.DoctorState(
405
+ symlink_state=symlink_state,
406
+ path_includes_local_bin=path_includes,
407
+ # Issue #119: availability-aware install checks (precomputed above).
408
+ cctally_reachable_on_path=cctally_reachable_on_path,
409
+ symlinks_path_pinned=symlinks_path_pinned,
410
+ install_is_brew=install_is_brew,
411
+ legacy_snippet=legacy_snippet,
412
+ legacy_bespoke=legacy_bespoke,
413
+ claude_settings=settings,
414
+ hook_counts=hook_counts,
415
+ log_activity_24h=activity,
416
+ oauth_token_present=oauth_token_present,
417
+ stats_db_status=stats_db_status,
418
+ cache_db_status=cache_db_status,
419
+ latest_snapshot_at=latest_snapshot_at,
420
+ cache_entries_count=cache_entries_count,
421
+ cache_last_entry_at=cache_last_entry_at,
422
+ claude_jsonl_present=claude_jsonl_present,
423
+ forked_bucket_counts=forked_bucket_counts,
424
+ credited_weeks=credited_weeks,
425
+ codex_entries_count=codex_entries_count,
426
+ codex_last_entry_at=codex_last_entry_at,
427
+ codex_jsonl_present=codex_jsonl_present,
428
+ dashboard_bind_stored=dashboard_bind_stored,
429
+ runtime_bind=runtime_bind,
430
+ config_json_error=config_json_error,
431
+ update_state=update_state,
432
+ update_state_error=update_state_error,
433
+ update_suppress=update_suppress,
434
+ update_suppress_error=update_suppress_error,
435
+ effective_update_available=effective_update_available,
436
+ effective_update_reason=effective_update_reason,
437
+ now_utc=now_utc,
438
+ cctally_version=cctally_version,
439
+ # Dev-instance isolation (§4): which data dir resolved + how.
440
+ dev_mode=_cctally_core.DEV_MODE,
441
+ app_dir=str(_cctally_core.APP_DIR),
442
+ is_dev_checkout=_cctally_core._is_dev_checkout(),
443
+ # Pricing-freshness check (spec §5.1): trailing-30d coverage gaps.
444
+ pricing_coverage=pricing_coverage,
445
+ )
446
+
447
+
448
+ def cmd_doctor(args: argparse.Namespace) -> int:
449
+ """Run all doctor checks and emit the report. Spec §4, §7.3.
450
+
451
+ Calls the I/O chokepoint (doctor_gather_state) → pure kernel
452
+ (_lib_doctor.run_checks) → renderer (render_text or
453
+ serialize_json). The argparse `add_mutually_exclusive_group`
454
+ handles the --quiet/--verbose collision at parse time; the
455
+ defense-in-depth check here covers programmatic invocation that
456
+ bypasses argparse.
457
+
458
+ Exit code follows the loose mapping in spec §4.5: 0 unless
459
+ overall_severity == "fail", then 2. Note that warn → 0; doctor
460
+ is read-only and warn-class findings are advisories, not errors.
461
+ """
462
+ import _lib_doctor
463
+ c = _cctally()
464
+ quiet = bool(getattr(args, "quiet", False))
465
+ verbose = bool(getattr(args, "verbose", False))
466
+ if quiet and verbose:
467
+ eprint("doctor: --quiet and --verbose are mutually exclusive")
468
+ return 2
469
+ state = c.doctor_gather_state()
470
+ report = _lib_doctor.run_checks(state)
471
+ if getattr(args, "json", False):
472
+ print(json.dumps(
473
+ _lib_doctor.serialize_json(report), indent=2, sort_keys=True,
474
+ ))
475
+ else:
476
+ sys.stdout.write(_lib_doctor.render_text(
477
+ report, quiet=quiet, verbose=verbose,
478
+ ))
479
+ return 2 if report.overall_severity == "fail" else 0