zeno-cli 0.3.4__py3-none-any.whl

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.
Files changed (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. zeno_session_intel/taxonomy.py +71 -0
zeno_cli/doctor.py ADDED
@@ -0,0 +1,535 @@
1
+ """zeno doctor - diagnostic suite for the local install.
2
+
3
+ One-shot health check the operator runs when something feels off. Each row
4
+ is a single check that prints a colored status (PASS/WARN/FAIL) + a detail
5
+ string. Exit code: 0 if every row is PASS, 1 if any row is FAIL. WARN rows
6
+ do NOT fail the run - they're "look here next" hints.
7
+
8
+ Checks (in execution order):
9
+ 1. Python version (PASS >=3.12, FAIL <3.12)
10
+ 2. zeno-cli package version (PASS if version_string() resolves)
11
+ 3. Local SQLite DB exists at ~/.zeno/zeno.db
12
+ 4. Last survey timestamp (WARN if >7 days, PASS otherwise, WARN if absent)
13
+ 5. API reachability via GET /v1/health (WARN if unreachable; zeno is local-first)
14
+ 6. Tailnet identity via GET /v1/me (PASS if identity comes back, WARN otherwise)
15
+ 7. Cognitive-state cache freshness at ~/.zeno/cognitive-state.json (WARN if absent/stale)
16
+ 8. Capture hook installed (checks settings.json AND settings.local.json; WARN if absent)
17
+ 9. Session-intel (ingest/usage) analytics surface importable (WARN if absent)
18
+ 10. Bunmail reachability (only when ZENO_BUNMAIL_BASE_URL set)
19
+
20
+ The doctor is intentionally chatty (one row per check, not collapsed) so
21
+ the operator can paste the output into a support thread or self-diagnose.
22
+ Network checks default to a 2s timeout each so a doctor run finishes in
23
+ under 10s even when everything is unreachable.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shutil
30
+ import sqlite3
31
+ import subprocess
32
+ import sys
33
+ from dataclasses import dataclass
34
+ from datetime import datetime, timedelta
35
+ from pathlib import Path
36
+ from urllib.error import HTTPError, URLError
37
+ from urllib.request import Request, urlopen
38
+
39
+ from .version import version_string
40
+
41
+ # Status codes used by every row. Mirrors `zeno email check-dns`.
42
+ STATUS_PASS = "PASS"
43
+ STATUS_WARN = "WARN"
44
+ STATUS_FAIL = "FAIL"
45
+
46
+ # Timeout for every network probe (seconds). Total wall-time for a doctor
47
+ # run with two unreachable hosts is bounded by NUM_NET_CHECKS * NET_TIMEOUT.
48
+ NET_TIMEOUT = 2.0
49
+
50
+ # Last-survey staleness threshold. Past this, doctor flags WARN; past 14
51
+ # days it would arguably FAIL, but Mar's PM2 dogfood policy is "log every
52
+ # coding session", and weekly-only counts as WARN-worthy not broken.
53
+ SURVEY_STALE_DAYS = 7
54
+
55
+ # Cognitive-state cache file lives next to the SQLite DB. Per research 2
56
+ # the cache is touched on every /v1/cognitive-state read; older than 30
57
+ # minutes means either the API is unreachable or no GET has happened in
58
+ # a while - both worth flagging WARN.
59
+ COGNITIVE_STATE_STALE_MINUTES = 30
60
+
61
+
62
+ @dataclass(slots=True, frozen=True)
63
+ class DoctorCheck:
64
+ """One row of the doctor output. label is the diagnostic name, status
65
+ is PASS/WARN/FAIL, detail is a short context string."""
66
+
67
+ label: str
68
+ status: str
69
+ detail: str
70
+
71
+
72
+ def _http_get(
73
+ url: str, *, timeout: float, headers: dict[str, str] | None = None
74
+ ) -> tuple[int, str]:
75
+ """Best-effort GET. Returns (status_code, body) or (0, error_message) on failure.
76
+
77
+ A status_code of 0 is the sentinel for "couldn't even open the socket";
78
+ the caller treats it as FAIL. HTTP error responses (401/500/etc.) still
79
+ surface their code so the operator can see the failure mode.
80
+ """
81
+ request_headers = {"Accept": "application/json"}
82
+ if headers:
83
+ request_headers.update(headers)
84
+ request = Request(url=url, method="GET", headers=request_headers)
85
+ try:
86
+ with urlopen(request, timeout=timeout) as response:
87
+ return response.status, response.read().decode("utf-8", errors="replace")
88
+ except HTTPError as exc:
89
+ try:
90
+ body = exc.read().decode("utf-8", errors="replace")
91
+ except (OSError, AttributeError):
92
+ body = str(exc.reason)
93
+ return exc.code, body
94
+ except (URLError, OSError) as exc:
95
+ return 0, str(exc)
96
+
97
+
98
+ def check_python_version() -> DoctorCheck:
99
+ """Pass when Python >= 3.12, the minimum declared in apps/cli/pyproject.toml."""
100
+ major, minor = sys.version_info[:2]
101
+ label = "Python version"
102
+ detail = f"{major}.{minor}.{sys.version_info.micro}"
103
+ if (major, minor) >= (3, 12):
104
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=detail)
105
+ return DoctorCheck(
106
+ label=label,
107
+ status=STATUS_FAIL,
108
+ detail=f"{detail} (requires 3.12+)",
109
+ )
110
+
111
+
112
+ def check_zeno_version() -> DoctorCheck:
113
+ """Always passes when version_string() resolves to something printable.
114
+
115
+ The point of this row is to fingerprint the install in the doctor
116
+ output so support can answer "what version are you on" without a
117
+ follow-up question.
118
+ """
119
+ version = version_string()
120
+ return DoctorCheck(
121
+ label="zeno-cli version",
122
+ status=STATUS_PASS,
123
+ detail=version,
124
+ )
125
+
126
+
127
+ def check_local_db(db_path: Path) -> DoctorCheck:
128
+ """SQLite DB lives at $ZENO_DB_PATH or ~/.zeno/zeno.db. Verify it exists
129
+ and is openable. A missing DB is WARN not FAIL - a brand-new install
130
+ that hasn't run `zeno survey` yet is a valid state.
131
+ """
132
+ label = "Local SQLite DB"
133
+ if not db_path.exists():
134
+ return DoctorCheck(
135
+ label=label,
136
+ status=STATUS_WARN,
137
+ detail=f"{db_path} (run 'zeno survey' to create)",
138
+ )
139
+ try:
140
+ conn = sqlite3.connect(str(db_path))
141
+ try:
142
+ # Read sqlite_master, not `SELECT 1`. `SELECT 1` is a constant that
143
+ # never touches the file, so a corrupt / non-sqlite file slips
144
+ # through - and inconsistently across sqlite builds (it raised on
145
+ # macOS sqlite but PASSed on the CI runner's sqlite). Reading the
146
+ # schema forces a page-1/header read, which raises SQLITE_NOTADB on
147
+ # a genuinely bad file uniformly.
148
+ conn.execute("SELECT count(*) FROM sqlite_master").fetchone()
149
+ finally:
150
+ conn.close()
151
+ except sqlite3.Error as exc:
152
+ return DoctorCheck(label=label, status=STATUS_FAIL, detail=f"{db_path}: {exc}")
153
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=str(db_path))
154
+
155
+
156
+ def check_last_survey(db_path: Path) -> DoctorCheck:
157
+ """Read the most recent load_probes.responded_at and rate its staleness.
158
+
159
+ No DB / no probes -> WARN with the bootstrap hint. Today -> PASS.
160
+ 1-7 days -> PASS with a soft hint. >7 days -> WARN (the streak is
161
+ probably broken too; doctor surfaces this so the next action is
162
+ obvious).
163
+ """
164
+ label = "Last survey"
165
+ if not db_path.exists():
166
+ return DoctorCheck(
167
+ label=label,
168
+ status=STATUS_WARN,
169
+ detail="no DB yet (run 'zeno survey')",
170
+ )
171
+ try:
172
+ conn = sqlite3.connect(str(db_path))
173
+ try:
174
+ row = conn.execute(
175
+ "SELECT MAX(responded_at) FROM load_probes WHERE responded_at IS NOT NULL"
176
+ ).fetchone()
177
+ finally:
178
+ conn.close()
179
+ except sqlite3.Error as exc:
180
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"query failed: {exc}")
181
+
182
+ if row is None or row[0] is None:
183
+ return DoctorCheck(
184
+ label=label,
185
+ status=STATUS_WARN,
186
+ detail="no surveys yet (run 'zeno survey')",
187
+ )
188
+
189
+ try:
190
+ ts = datetime.fromisoformat(str(row[0]))
191
+ except ValueError:
192
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"unparseable: {row[0]!r}")
193
+
194
+ now = datetime.now(ts.tzinfo) if ts.tzinfo else datetime.now()
195
+ delta = now - ts
196
+ iso = ts.isoformat(timespec="seconds")
197
+ if delta < timedelta(days=1):
198
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=f"today ({iso})")
199
+ if delta <= timedelta(days=SURVEY_STALE_DAYS):
200
+ return DoctorCheck(
201
+ label=label,
202
+ status=STATUS_PASS,
203
+ detail=f"{delta.days}d ago ({iso})",
204
+ )
205
+ return DoctorCheck(
206
+ label=label,
207
+ status=STATUS_WARN,
208
+ detail=f"{delta.days}d ago ({iso}) - run 'zeno survey'",
209
+ )
210
+
211
+
212
+ def check_capture_hook(settings_path: Path | None = None) -> DoctorCheck:
213
+ """Is the zeno capture hook wired into Claude Code's settings.json?
214
+
215
+ A CLI install with no ``zeno hook install`` captures ZERO sessions, yet every
216
+ other row still PASSes - so doctor surfaces it. WARN, not FAIL: capture is opt-in
217
+ (some users only want the CLI), so a missing hook should not fail the run.
218
+
219
+ Presence uses the same dual-file resolution rule as the HUD installer's own
220
+ presence check (``hud.hud_install.capture_hook_present``) - an independent read here,
221
+ not a shared call, so keep the two in sync by hand: when no explicit path is given,
222
+ BOTH ``settings.json`` AND its sibling ``settings.local.json`` are consulted.
223
+ That dual lookup matters on a symlinked-dotfiles machine (Mar's own setup): when
224
+ ``settings.json`` is a symlink into a shared dotfiles checkout, ``zeno hud install``
225
+ redirects the write to the host-local ``settings.local.json``, so reading only
226
+ ``settings.json`` here FALSE-WARNed "not installed" even when capture was live.
227
+
228
+ An explicit ``settings_path`` is honored verbatim (single file) - that's the contract
229
+ the CLI's ``--settings-path`` and the test suite rely on; the settings.local.json
230
+ fallback only kicks in for the default (``settings_path is None``) path.
231
+ """
232
+ from .hook_install import HOOK_EVENTS, default_settings_path, status # noqa: PLC0415
233
+
234
+ label = "Capture hook"
235
+ n = len(HOOK_EVENTS)
236
+
237
+ if settings_path is not None:
238
+ candidates: list[Path] = [settings_path]
239
+ else:
240
+ base = default_settings_path()
241
+ candidates = [base, base.with_name("settings.local.json")]
242
+
243
+ # Read every candidate; keep the status with the MOST installed events (so a hook in
244
+ # settings.local.json wins over an empty/symlinked settings.json). `read_ok` tracks
245
+ # whether any candidate was readable at all, to distinguish "not installed" from
246
+ # "could not read".
247
+ best = None
248
+ read_ok = False
249
+ for candidate in candidates:
250
+ try:
251
+ cand_st = status(candidate)
252
+ except Exception:
253
+ continue
254
+ read_ok = True
255
+ if best is None or len(cand_st["events_installed"]) > len(best["events_installed"]):
256
+ best = cand_st
257
+
258
+ if not read_ok or best is None:
259
+ return DoctorCheck(
260
+ label=label, status=STATUS_WARN, detail=f"could not read {candidates[0]}"
261
+ )
262
+ if best["all_events_installed"]:
263
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=f"installed ({n} events)")
264
+ if best["events_installed"]:
265
+ got = len(best["events_installed"])
266
+ return DoctorCheck(
267
+ label=label,
268
+ status=STATUS_WARN,
269
+ detail=f"partial ({got}/{n} events); re-run 'zeno hook install'",
270
+ )
271
+ return DoctorCheck(
272
+ label=label,
273
+ status=STATUS_WARN,
274
+ detail="not installed - run 'zeno hook install' to capture sessions",
275
+ )
276
+
277
+
278
+ def check_api_health(base_url: str) -> DoctorCheck:
279
+ """GET /v1/health on the configured API base URL.
280
+
281
+ PASS on 200. Anything else is WARN, never FAIL: zeno is local-first, so an
282
+ unreachable API (an off-tailnet teammate) or a flaky server does not break
283
+ local capture + survey + curve. Only broken local dependencies (Python, the CLI,
284
+ the SQLite DB) FAIL on an end-user install, so a fresh local-only install is green.
285
+ (A repo checkout adds a dev-only ``Codegen`` row that FAILs on UNCOMMITTED generated
286
+ code - a real dev error, not a runtime dependency; it never exists on a wheel install.)
287
+ """
288
+ label = "API reachability"
289
+ url = f"{base_url.rstrip('/')}/v1/health"
290
+ code, body = _http_get(url, timeout=NET_TIMEOUT)
291
+ if code == 200:
292
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=f"200 {url}")
293
+ if code == 0:
294
+ return DoctorCheck(
295
+ label=label,
296
+ status=STATUS_WARN,
297
+ detail=f"unreachable - local-only mode (not on the tailnet?): {body[:50]}",
298
+ )
299
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code} {url}")
300
+
301
+
302
+ def check_tailnet_identity(base_url: str) -> DoctorCheck:
303
+ """GET /v1/me. PASS if a tier is returned. WARN if 401 (no identity yet),
304
+ WARN if API unreachable (the previous check already FAILed that).
305
+ """
306
+ label = "Tailnet identity"
307
+ # Centralized resolution: ZENO_API_TOKEN override, else the `zeno login`
308
+ # keyring token. Lazy import avoids a circular dependency (main imports
309
+ # doctor at module load).
310
+ from .main import resolve_api_token # noqa: PLC0415
311
+
312
+ token = resolve_api_token()
313
+ headers = {"Authorization": f"Bearer {token}"} if token else None
314
+ url = f"{base_url.rstrip('/')}/v1/me"
315
+ code, body = _http_get(url, timeout=NET_TIMEOUT, headers=headers)
316
+ if code == 200:
317
+ try:
318
+ import json # noqa: PLC0415
319
+
320
+ parsed = json.loads(body)
321
+ user = parsed.get("user_id", "<no user_id>")
322
+ tier = parsed.get("tier", "<no tier>")
323
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=f"{user} tier={tier}")
324
+ except (ValueError, AttributeError):
325
+ return DoctorCheck(
326
+ label=label, status=STATUS_WARN, detail=f"200 but malformed: {body[:60]}"
327
+ )
328
+ if code == 401:
329
+ return DoctorCheck(
330
+ label=label,
331
+ status=STATUS_WARN,
332
+ detail="401 (tailnet identity not present; ok for non-tailnet callers)",
333
+ )
334
+ if code == 0:
335
+ return DoctorCheck(label=label, status=STATUS_WARN, detail="skipped (API unreachable)")
336
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code}: {body[:60]}")
337
+
338
+
339
+ def check_cognitive_state_cache(cache_path: Path) -> DoctorCheck:
340
+ """~/.zeno/cognitive-state.json is the shared-memory cache from research 2.
341
+
342
+ Phase 0 contract: the API rewrites this file on every authed
343
+ /v1/cognitive-state read for the local operator. Doctor flags WARN
344
+ when the file is missing (Phase 0 not wired yet for this user) or
345
+ when the mtime is older than COGNITIVE_STATE_STALE_MINUTES (no recent
346
+ GET, so the gate is operating on stale info).
347
+ """
348
+ label = "Cognitive-state cache"
349
+ if not cache_path.exists():
350
+ return DoctorCheck(
351
+ label=label,
352
+ status=STATUS_WARN,
353
+ detail=f"{cache_path} not present (call GET /v1/cognitive-state to populate)",
354
+ )
355
+ try:
356
+ mtime = datetime.fromtimestamp(cache_path.stat().st_mtime)
357
+ except OSError as exc:
358
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"stat failed: {exc}")
359
+ age = datetime.now() - mtime
360
+ minutes = int(age.total_seconds() // 60)
361
+ if age > timedelta(minutes=COGNITIVE_STATE_STALE_MINUTES):
362
+ return DoctorCheck(
363
+ label=label,
364
+ status=STATUS_WARN,
365
+ detail=f"stale ({minutes}m old)",
366
+ )
367
+ return DoctorCheck(
368
+ label=label,
369
+ status=STATUS_PASS,
370
+ detail=f"fresh ({minutes}m old)",
371
+ )
372
+
373
+
374
+ def check_bunmail() -> DoctorCheck | None:
375
+ """Only runs when ZENO_BUNMAIL_BASE_URL is set. Returns None when env is
376
+ absent so doctor doesn't print a misleading "bunmail FAIL" row to
377
+ operators who don't use the outreach surface.
378
+
379
+ Bunmail's health endpoint is /health (no /v1 prefix - see bunmail repo).
380
+ Treating 200 as PASS and anything else as WARN; a flaky bunmail does
381
+ NOT break the rest of zeno.
382
+ """
383
+ base = os.environ.get("ZENO_BUNMAIL_BASE_URL", "").strip()
384
+ if not base:
385
+ return None
386
+ label = "Bunmail reachability"
387
+ url = f"{base.rstrip('/')}/health"
388
+ code, body = _http_get(url, timeout=NET_TIMEOUT)
389
+ if code == 200:
390
+ return DoctorCheck(label=label, status=STATUS_PASS, detail=f"200 {url}")
391
+ if code == 0:
392
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"unreachable: {body}")
393
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"{code} {url}")
394
+
395
+
396
+ def _find_workspace_root() -> Path | None:
397
+ """Nearest ancestor of this file that is a zeno workspace checkout.
398
+
399
+ Identified by a `.git` dir + the `tools/codegen` tree. Returns None for a
400
+ wheel install in site-packages (no repo nearby) so the dev row is omitted
401
+ for end users, mirroring how check_bunmail is conditional.
402
+ """
403
+ here = Path(__file__).resolve()
404
+ for parent in [here.parent, *here.parents][:10]:
405
+ if (parent / ".git").exists() and (parent / "tools" / "codegen").is_dir():
406
+ return parent
407
+ return None
408
+
409
+
410
+ def check_codegen_drift() -> DoctorCheck | None:
411
+ """Dev-only: are the codegen tools present and the generated SDK committed?
412
+
413
+ Returns None outside a workspace checkout. In a workspace:
414
+ - FAIL if uv or pnpm is missing (you cannot run `pnpm codegen`).
415
+ - FAIL if the generated SDK tree (sdk-python _generated + types) has
416
+ uncommitted changes - a regenerate that was not committed, or a
417
+ hand-edit of a generated file - with a remediation hint.
418
+ - PASS otherwise (tools present, generated tree clean). This is a fast,
419
+ read-only proxy; it does NOT run codegen (too heavy for a doctor run),
420
+ so the detail points at `pnpm codegen` for the authoritative check.
421
+ """
422
+ root = _find_workspace_root()
423
+ if root is None:
424
+ return None
425
+ label = "Codegen (dev)"
426
+
427
+ missing = [t for t in ("uv", "pnpm") if shutil.which(t) is None]
428
+ if missing:
429
+ # WARN not FAIL: a missing BUILD tool is not a broken runtime/local dependency,
430
+ # so it must not turn an otherwise-working checkout red (doctor_exit_code contract).
431
+ return DoctorCheck(
432
+ label=label,
433
+ status=STATUS_WARN,
434
+ detail=f"{', '.join(missing)} not on PATH - skipping the codegen drift check",
435
+ )
436
+
437
+ gen_paths = [
438
+ "packages/sdk-python/src/zeno_sdk/_generated",
439
+ "packages/sdk-python/src/zeno_sdk/types",
440
+ ]
441
+ try:
442
+ out = subprocess.run(
443
+ ["git", "status", "--porcelain", "--", *gen_paths],
444
+ capture_output=True,
445
+ text=True,
446
+ timeout=3,
447
+ cwd=str(root),
448
+ )
449
+ except (OSError, subprocess.SubprocessError) as exc:
450
+ return DoctorCheck(label=label, status=STATUS_WARN, detail=f"git status failed: {exc}")
451
+
452
+ dirty = [ln for ln in out.stdout.splitlines() if ln.strip()]
453
+ if dirty:
454
+ return DoctorCheck(
455
+ label=label,
456
+ status=STATUS_FAIL,
457
+ detail=(
458
+ f"{len(dirty)} generated SDK file(s) uncommitted - run 'pnpm codegen' "
459
+ "and commit, or 'git restore' them"
460
+ ),
461
+ )
462
+ return DoctorCheck(
463
+ label=label,
464
+ status=STATUS_PASS,
465
+ detail="uv+pnpm present, generated SDK committed (run 'pnpm codegen' to re-verify)",
466
+ )
467
+
468
+
469
+ def check_session_intel() -> DoctorCheck:
470
+ """Is the session-intel (ingest / usage) analytics surface importable?
471
+
472
+ ``zeno ingest`` / ``zeno usage`` fold the standalone ``zeno_session_intel`` sub-CLIs
473
+ (ingest + analytics) under the main CLI. That package is a workspace package, NOT
474
+ bundled into the zeno-cli wheel, so a bare wheel install lacks it and those two
475
+ commands print a hint instead of running. WARN, not FAIL, when absent: it is an
476
+ additive analytics surface, not a hard runtime dependency (doctor_exit_code
477
+ contract), so its absence must never turn an otherwise-healthy install red. This row
478
+ fingerprints whether the surface is wired so the operator isn't surprised by a hint.
479
+ """
480
+ import importlib # noqa: PLC0415
481
+
482
+ label = "Session-intel (ingest/usage)"
483
+ try:
484
+ importlib.import_module("zeno_session_intel.ingest")
485
+ importlib.import_module("zeno_session_intel.analytics")
486
+ except ImportError:
487
+ return DoctorCheck(
488
+ label=label,
489
+ status=STATUS_WARN,
490
+ detail="not importable - 'zeno ingest'/'zeno usage' need the "
491
+ "zeno-session-intel package (run from the workspace or install it)",
492
+ )
493
+ return DoctorCheck(
494
+ label=label,
495
+ status=STATUS_PASS,
496
+ detail="importable ('zeno ingest' / 'zeno usage' available)",
497
+ )
498
+
499
+
500
+ def run_doctor(
501
+ *, base_url: str, db_path: Path, cache_path: Path, settings_path: Path | None = None
502
+ ) -> list[DoctorCheck]:
503
+ """Execute every doctor check and return the list of DoctorCheck rows.
504
+
505
+ Pure-ish: each row is a function call; the rendering layer in main.py
506
+ handles colors and the exit code computation. Keeping this list-of-rows
507
+ shape makes the doctor easy to unit-test (no terminal mocking) and
508
+ easy to extend (append a new check).
509
+ """
510
+ checks: list[DoctorCheck] = [
511
+ check_python_version(),
512
+ check_zeno_version(),
513
+ check_local_db(db_path),
514
+ check_last_survey(db_path),
515
+ check_capture_hook(settings_path),
516
+ check_session_intel(),
517
+ check_api_health(base_url),
518
+ check_tailnet_identity(base_url),
519
+ check_cognitive_state_cache(cache_path),
520
+ ]
521
+ bunmail = check_bunmail()
522
+ if bunmail is not None:
523
+ checks.append(bunmail)
524
+ codegen = check_codegen_drift()
525
+ if codegen is not None:
526
+ checks.append(codegen)
527
+ return checks
528
+
529
+
530
+ def doctor_exit_code(checks: list[DoctorCheck]) -> int:
531
+ """0 if every row is PASS or WARN, 1 if any row is FAIL. WARN does not
532
+ fail the run - that's the explicit contract documented at the top of
533
+ this file. A FAIL row means a hard dependency is broken.
534
+ """
535
+ return 1 if any(c.status == STATUS_FAIL for c in checks) else 0