cctally 1.22.2 → 1.22.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/bin/_cctally_cache_report.py +1133 -880
- package/bin/_cctally_codex.py +518 -0
- package/bin/_cctally_dashboard.py +3 -3
- package/bin/_cctally_diff.py +240 -0
- package/bin/_cctally_doctor.py +479 -0
- package/bin/_cctally_five_hour.py +1688 -0
- package/bin/_cctally_forecast.py +1979 -0
- package/bin/_cctally_milestones.py +433 -0
- package/bin/_cctally_percent_breakdown.py +199 -0
- package/bin/_cctally_pricing_check.py +393 -0
- package/bin/_cctally_record.py +5 -3
- package/bin/_cctally_reporting.py +749 -0
- package/bin/_cctally_setup.py +172 -13
- package/bin/_cctally_statusline.py +630 -0
- package/bin/_cctally_sync_week.py +5 -4
- package/bin/_cctally_weekrefs.py +450 -0
- package/bin/_lib_cache_report.py +938 -0
- package/bin/_lib_pricing_debug.py +182 -0
- package/bin/_lib_subscription_weeks.py +2 -2
- package/bin/cctally +419 -8891
- package/package.json +14 -1
|
@@ -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
|