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/main.py ADDED
@@ -0,0 +1,2534 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import asyncio
5
+ import difflib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import uuid
13
+ from datetime import date, datetime
14
+ from pathlib import Path
15
+
16
+ from zeno_core import (
17
+ SessionPoint,
18
+ StreakResult,
19
+ fit_babysitting_tax_curve,
20
+ has_logged_today,
21
+ read_streak_from_local_storage,
22
+ render_curve_ascii,
23
+ render_weekly_table,
24
+ save_curve_png,
25
+ weekly_summary,
26
+ )
27
+ from zeno_core.rtlx_s import RTLXS_AUTONOMY_CONDITIONS, run_rtlxs_survey_tui
28
+ from zeno_sdk import Zeno, auth
29
+ from zeno_sdk._generated.client import ApiError, ZenoApiClient
30
+
31
+ from .doctor import (
32
+ STATUS_FAIL,
33
+ STATUS_PASS,
34
+ STATUS_WARN,
35
+ doctor_exit_code,
36
+ run_doctor,
37
+ )
38
+ from .version import version_string
39
+
40
+ DEFAULT_API_BASE_URL = os.environ.get(
41
+ "ZENO_API_BASE_URL", "https://zeno-api-364453955482.us-west1.run.app"
42
+ )
43
+
44
+ # Default dashboard CLI-authorize bridge for `zeno login`. Canonical copy lives
45
+ # in zeno_cli.login.DEFAULT_AUTHORIZE_URL; duplicated here (same env var) so the
46
+ # hot `zeno status` path - which builds the full parser - does not have to
47
+ # import the login module (http.server / webbrowser) just to set a default.
48
+ DEFAULT_AUTHORIZE_URL = os.environ.get(
49
+ "ZENO_LOGIN_AUTHORIZE_URL", "https://app.zeno.center/cli/authorize"
50
+ )
51
+
52
+
53
+ def resolve_api_token() -> str | None:
54
+ """Resolve the API bearer token, env override first, then the OS keyring.
55
+
56
+ ZENO_API_TOKEN wins so existing overrides + tests keep working as-is.
57
+ Otherwise fall back to the Clerk JWT stored by `zeno login`
58
+ (zeno_sdk.auth keyring). Returns None when neither is set (free-tier /
59
+ not logged in), which callers treat as "local-only / offline".
60
+ """
61
+ token = os.environ.get("ZENO_API_TOKEN")
62
+ if token:
63
+ return token
64
+ return auth.get_token()
65
+
66
+
67
+ # Default location of the cognitive-state cache mirror written by the API
68
+ # (see apps/api/src/zeno_api/cognitive_state.py). Read by `zeno status`
69
+ # and `zeno doctor`; tunable via ZENO_HOME just like the SQLite DB.
70
+ DEFAULT_COGNITIVE_STATE_CACHE = (
71
+ Path(os.environ.get("ZENO_HOME", str(Path.home() / ".zeno"))).expanduser()
72
+ / "cognitive-state.json"
73
+ )
74
+
75
+ # Default location of the local SQLite DB (matches packages/sdk-python/config.py).
76
+ # Doctor uses this directly so it can probe before the SDK is initialized.
77
+ DEFAULT_DB_PATH = Path(
78
+ os.environ.get(
79
+ "ZENO_DB_PATH",
80
+ str(Path(os.environ.get("ZENO_HOME", str(Path.home() / ".zeno"))).expanduser() / "zeno.db"),
81
+ )
82
+ ).expanduser()
83
+
84
+ # Daily-rotating tip pool. Index = day-of-year % len(TIPS). Keep tips short,
85
+ # action-verb-first, focused on free-tier value (local measurement + the curve).
86
+ TIPS: tuple[str, ...] = (
87
+ "Run a survey after every coding session: 'zeno survey'. The RTLX-S 5-item probe "
88
+ "calibrates your supervision-vs-execution split faster than passive logging.",
89
+ "Check 'zeno curve' once a week to see where your babysitting tax kicks in. "
90
+ "Optimal-N drops by 1 agent every time it shifts; act on it.",
91
+ "Open the dashboard for weekly trends - 'zeno weekly' prints a digest, but "
92
+ "the dashboard at zeno.center plots quality decay across runs.",
93
+ )
94
+
95
+
96
+ def _color(text: str, code: str, *, enabled: bool) -> str:
97
+ """Wrap text in an ANSI color code, no-op if colors are disabled."""
98
+ if not enabled:
99
+ return text
100
+ return f"\033[{code}m{text}\033[0m"
101
+
102
+
103
+ def _colors_enabled() -> bool:
104
+ """Match the rich convention: respect NO_COLOR; default on for ttys."""
105
+ if os.environ.get("NO_COLOR"):
106
+ return False
107
+ return True
108
+
109
+
110
+ def _print(text: str = "") -> None:
111
+ """Plain print. Kept as a seam so rich-based output can be swapped in if
112
+ the env has rich available.
113
+
114
+ rich's `print` interprets ``[...]`` as console-markup tags, so bracketed
115
+ status labels (``[dry-run]``, ``[high-intent, signed: ...]``) get silently
116
+ eaten before reaching the terminal. Every call site here emits plain text,
117
+ never rich markup, so we escape the literal first: the bracket survives to
118
+ the terminal instead of being parsed as a tag. This function applies no
119
+ color itself - it is a plain-text seam, not a styling layer."""
120
+ try:
121
+ from rich import print as rprint
122
+ from rich.markup import escape
123
+
124
+ rprint(escape(text))
125
+ except ImportError:
126
+ print(text)
127
+
128
+
129
+ # Top-level subcommand verbs, kept as the single source of truth for the
130
+ # "did you mean" suggester and the shell-completion generator. Mirrors the
131
+ # choices registered on the subparsers below; keep in sync by hand (cheap).
132
+ TOP_LEVEL_COMMANDS: tuple[str, ...] = (
133
+ "survey",
134
+ "report",
135
+ "curve",
136
+ "weekly",
137
+ "status",
138
+ "tips",
139
+ "onboard",
140
+ "doctor",
141
+ "version",
142
+ "billing",
143
+ "ingest",
144
+ "usage",
145
+ "metrics",
146
+ "hook",
147
+ "hud",
148
+ "email",
149
+ "outreach",
150
+ "completion",
151
+ )
152
+
153
+
154
+ class _ZenoArgumentParser(argparse.ArgumentParser):
155
+ """argparse parser that turns the wall-of-text 'invalid choice' error into
156
+ an actionable one-liner: a 'did you mean X?' suggestion (difflib) plus a
157
+ pointer to the grouped index. The default argparse message dumps the full
158
+ choice list with no nearest-match hint, which is poor UX for a typo."""
159
+
160
+ def error(self, message: str) -> None: # type: ignore[override]
161
+ typo = _extract_invalid_choice(message)
162
+ if typo is not None:
163
+ suggestion = _closest_command(typo)
164
+ lines = [f"zeno: unknown command '{typo}'."]
165
+ if suggestion:
166
+ lines.append(f" Did you mean '{suggestion}'?")
167
+ lines.append(" Run 'zeno' for the command index or 'zeno --help' for full usage.")
168
+ print("\n".join(lines), file=sys.stderr)
169
+ raise SystemExit(2)
170
+ # Fall back to argparse's default for everything else (missing required
171
+ # subcommand args, bad flags, etc.).
172
+ super().error(message)
173
+
174
+
175
+ def _extract_invalid_choice(message: str) -> str | None:
176
+ """Pull the offending token out of argparse's 'invalid choice: 'X'' message
177
+ for the top-level command argument only (nested subparsers keep the default
178
+ error, which already names their own choices)."""
179
+ marker = "argument command: invalid choice: '"
180
+ if marker not in message:
181
+ return None
182
+ rest = message.split(marker, 1)[1]
183
+ end = rest.find("'")
184
+ return rest[:end] if end != -1 else None
185
+
186
+
187
+ def _closest_command(token: str) -> str | None:
188
+ """Nearest top-level command to a typo, or None if nothing is close."""
189
+ matches = difflib.get_close_matches(token, TOP_LEVEL_COMMANDS, n=1, cutoff=0.5)
190
+ return matches[0] if matches else None
191
+
192
+
193
+ def _build_parser() -> argparse.ArgumentParser:
194
+ parser = _ZenoArgumentParser(prog="zeno")
195
+ parser.add_argument(
196
+ "--no-probes",
197
+ action="store_true",
198
+ help="Disable probe prompts and log skips.",
199
+ )
200
+ # Top-level --version is the universal CLI convention (git/curl/gh).
201
+ # Mirror it as `zeno version` further down for callers who prefer the
202
+ # subcommand shape (doctl, fly, kubectl all expose both).
203
+ parser.add_argument(
204
+ "--version",
205
+ action="version",
206
+ version=f"zeno {version_string()}",
207
+ )
208
+
209
+ # Subparsers intentionally NOT required: bare `zeno` defaults to `zeno
210
+ # status`. Matches the gh / doctl / fly convention where the zero-arg
211
+ # surface is a fast "where am I" view, not a usage error. See research
212
+ # prompt #24 (RESEARCH_PROMPTS_2026-06-07.md).
213
+ subparsers = parser.add_subparsers(dest="command")
214
+ survey = subparsers.add_parser(
215
+ "survey",
216
+ help="Run RTLX-S 5-item probe and store response.",
217
+ )
218
+ survey.add_argument("--project", default="default-project", help="Project identifier.")
219
+ survey.add_argument("--harness", default="cli", help="Harness name for session tracking.")
220
+ survey_condition = survey.add_mutually_exclusive_group()
221
+ survey_condition.add_argument(
222
+ "--autonomy",
223
+ choices=list(RTLXS_AUTONOMY_CONDITIONS),
224
+ default=None,
225
+ help=(
226
+ "Randomized SCED condition for this session (high_autonomy / "
227
+ "low_autonomy / control). Assign per your pre-registered schedule. "
228
+ "Stores the condition in the clear - use --sced for the blinded v2 flow."
229
+ ),
230
+ )
231
+ survey_condition.add_argument(
232
+ "--sced",
233
+ action="store_true",
234
+ help=(
235
+ "Blinded SCED mode (rating-time concealment, pre-reg Section 12): never "
236
+ "shows or stores the condition, randomizes item order, and asks a forced "
237
+ "condition guess + confidence after the load items. Use with --sced-index; "
238
+ "dotfiles/scripts/sced-next.sh sets this and seals the true condition."
239
+ ),
240
+ )
241
+ survey.add_argument(
242
+ "--sced-index",
243
+ type=int,
244
+ default=None,
245
+ dest="sced_index",
246
+ help="Locked-schedule slot (1-indexed) for a --sced blinded session.",
247
+ )
248
+
249
+ report = subparsers.add_parser("report", help="Print the last-session summary.")
250
+ report.add_argument("--project", default="default-project", help="Project identifier.")
251
+
252
+ curve = subparsers.add_parser("curve", help="Render Babysitting Tax curve and save PNG.")
253
+ curve.add_argument("--project", default="default-project", help="Project identifier.")
254
+ curve.add_argument(
255
+ "--png",
256
+ default="zeno-curve.png",
257
+ help="Path to save curve PNG.",
258
+ )
259
+
260
+ weekly = subparsers.add_parser("weekly", help="Print weekly digest summary.")
261
+ weekly.add_argument("--project", default="default-project", help="Project identifier.")
262
+
263
+ status = subparsers.add_parser(
264
+ "status",
265
+ help="Print a one-screen summary: tier, last session, calibration state.",
266
+ )
267
+ status.add_argument("--project", default="default-project", help="Project identifier.")
268
+ status.add_argument(
269
+ "--base-url",
270
+ default=DEFAULT_API_BASE_URL,
271
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
272
+ )
273
+
274
+ subparsers.add_parser(
275
+ "tips",
276
+ help="Print a daily-rotating tip about getting more value out of zeno.",
277
+ )
278
+
279
+ # zeno onboard: the one-command fast-path for a new dogfooder. Orchestrates
280
+ # `hook install` + `hud install` + `doctor` end-to-end (idempotent, --dry-run,
281
+ # off-tailnet safe), then prints a BLUF summary. See onboard.py.
282
+ onboard = subparsers.add_parser(
283
+ "onboard",
284
+ help="One command to set up a fresh dogfooder: capture hook + HUD + doctor.",
285
+ )
286
+ onboard.add_argument(
287
+ "--base-url",
288
+ default=DEFAULT_API_BASE_URL,
289
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
290
+ )
291
+ onboard.add_argument(
292
+ "--dry-run",
293
+ action="store_true",
294
+ help="Print exactly what would change; write nothing.",
295
+ )
296
+ onboard.add_argument(
297
+ "--force",
298
+ action="store_true",
299
+ help="Replace an existing non-zeno statusLine with the zeno line.",
300
+ )
301
+ onboard.add_argument(
302
+ "--settings-path",
303
+ default=None,
304
+ help="Override settings.json (default resolves symlinks to settings.local.json).",
305
+ )
306
+ onboard.add_argument(
307
+ "--ccstatusline-path",
308
+ default=None,
309
+ help="Override the ccstatusline settings.json path.",
310
+ )
311
+
312
+ # zeno doctor: one-shot diagnostic. Mirrors brew/rustup/gh's UX -
313
+ # operator hits this when something feels off and gets a colored,
314
+ # row-per-check report + a non-zero exit when a hard dep is broken.
315
+ doctor = subparsers.add_parser(
316
+ "doctor",
317
+ help="Run diagnostic checks against the local install + API.",
318
+ )
319
+ doctor.add_argument(
320
+ "--base-url",
321
+ default=DEFAULT_API_BASE_URL,
322
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
323
+ )
324
+
325
+ # zeno version: dual to top-level --version. Mar's muscle memory may
326
+ # reach for the subcommand (kubectl style) before the flag, and the
327
+ # generated SDK + git SHA make the output worth keeping verbatim.
328
+ subparsers.add_parser(
329
+ "version",
330
+ help="Print zeno-cli version + git SHA when available.",
331
+ )
332
+
333
+ # Account: authenticate the CLI against Clerk so the API attributes requests
334
+ # per user. `login` runs a browser + loopback flow (state-CSRF, no PKCE) and stores the
335
+ # Clerk JWT in the OS keyring; `logout` clears it; `whoami` echoes the
336
+ # identity the API sees. The dashboard `/cli/authorize` bridge that mints the
337
+ # token is a documented follow-up (see login.py); --authorize-url + the
338
+ # ZENO_LOGIN_AUTHORIZE_URL env make it injectable for testing pre-bridge.
339
+ login_p = subparsers.add_parser(
340
+ "login",
341
+ help="Authenticate the CLI via the browser; store the token in the OS keyring.",
342
+ )
343
+ login_p.add_argument(
344
+ "--authorize-url",
345
+ default=DEFAULT_AUTHORIZE_URL,
346
+ help=(
347
+ "Dashboard CLI-authorize bridge URL (default: ZENO_LOGIN_AUTHORIZE_URL "
348
+ "or https://app.zeno.center/cli/authorize)."
349
+ ),
350
+ )
351
+ login_p.add_argument(
352
+ "--no-browser",
353
+ action="store_true",
354
+ help="Print the authorize URL instead of opening a browser (SSH / headless).",
355
+ )
356
+
357
+ subparsers.add_parser(
358
+ "logout",
359
+ help="Clear the stored Clerk token from the OS keyring.",
360
+ )
361
+
362
+ whoami_p = subparsers.add_parser(
363
+ "whoami",
364
+ help="Show the identity + tier the API attributes to the stored token.",
365
+ )
366
+ whoami_p.add_argument(
367
+ "--base-url",
368
+ default=DEFAULT_API_BASE_URL,
369
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
370
+ )
371
+
372
+ # Billing subcommands hit the public Stage 1 follow-up endpoints
373
+ # (recommend-tier + preview-checkout). No auth, no Stripe round-trip -
374
+ # they read the API's configured tier catalog. Useful for sanity-checking
375
+ # which Stripe SKUs are wired and what each tier resolves to.
376
+ billing = subparsers.add_parser("billing", help="Inspect Stage 1 billing catalog.")
377
+ billing_sub = billing.add_subparsers(dest="billing_command", required=True)
378
+
379
+ recommend = billing_sub.add_parser(
380
+ "recommend-tier", help="Recommend the cheapest tier for N engineers."
381
+ )
382
+ recommend.add_argument("--engineers", type=int, default=1, help="Engineer count.")
383
+ recommend.add_argument(
384
+ "--base-url",
385
+ default=DEFAULT_API_BASE_URL,
386
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
387
+ )
388
+
389
+ preview = billing_sub.add_parser(
390
+ "preview-tier", help="Show the configured Stripe price for (tier, period)."
391
+ )
392
+ preview.add_argument(
393
+ "--tier",
394
+ choices=["pro"],
395
+ default="pro",
396
+ )
397
+ preview.add_argument(
398
+ "--period",
399
+ choices=["monthly", "annual"],
400
+ default="monthly",
401
+ )
402
+ preview.add_argument(
403
+ "--base-url",
404
+ default=DEFAULT_API_BASE_URL,
405
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
406
+ )
407
+
408
+ # Session-intelligence folded in from the standalone zeno-ingest / zeno-usage
409
+ # entry points. Thin pass-through wrappers: add_help=False + REMAINDER so all
410
+ # args (incl. --help) forward verbatim to the sub-CLI, which owns parsing and
411
+ # the --write-live live-DB guard. Lazy-imported in the handler.
412
+ ingest = subparsers.add_parser(
413
+ "ingest",
414
+ help="Ingest coding-agent transcripts into the local capture DB (zeno-ingest).",
415
+ add_help=False,
416
+ )
417
+ ingest.add_argument(
418
+ "ingest_args",
419
+ nargs=argparse.REMAINDER,
420
+ help="forwarded to zeno-ingest (--tools, --db, --write-live, ...); try 'ingest --help'.",
421
+ )
422
+
423
+ usage = subparsers.add_parser(
424
+ "usage",
425
+ help="Session-intelligence rollups + full-text search over transcripts (zeno-usage).",
426
+ add_help=False,
427
+ )
428
+ usage.add_argument(
429
+ "usage_args",
430
+ nargs=argparse.REMAINDER,
431
+ help="forwarded to zeno-usage: --search, --limit, --db, ... (try 'usage --help').",
432
+ )
433
+
434
+ # Metrics: emit a usage event to the Stage 1.5 meter (POST /v1/usage/emit).
435
+ # Paid-tier endpoint; the API records what the caller asserts (event_type +
436
+ # credits + optional metadata). Downstream rollups compute P90 / margin
437
+ # erosion / the customer usage dashboard. See PLAN.md track #7.
438
+ metrics = subparsers.add_parser("metrics", help="Emit usage events to the meter.")
439
+ metrics_sub = metrics.add_subparsers(dest="metrics_command", required=True)
440
+
441
+ metrics_emit = metrics_sub.add_parser(
442
+ "emit",
443
+ help="Record a usage event (POST /v1/usage/emit).",
444
+ )
445
+ metrics_emit.add_argument(
446
+ "--event",
447
+ required=True,
448
+ help="Event type, e.g. 'session.completed' (1-64 chars).",
449
+ )
450
+ metrics_emit.add_argument(
451
+ "--quantity",
452
+ type=int,
453
+ default=1,
454
+ help="Credits to record for this event (default: 1).",
455
+ )
456
+ metrics_emit.add_argument(
457
+ "--metadata",
458
+ action="append",
459
+ metavar="KEY=VALUE",
460
+ default=None,
461
+ help="Optional metadata pair; repeatable (e.g. --metadata model=opus --metadata tool=cli).",
462
+ )
463
+ metrics_emit.add_argument(
464
+ "--base-url",
465
+ default=DEFAULT_API_BASE_URL,
466
+ help="Zeno API base URL (default: ZENO_API_BASE_URL or https://zeno-api-364453955482.us-west1.run.app).",
467
+ )
468
+
469
+ # Capture-hook installer: write/remove the cc-bridge hook in
470
+ # ~/.claude/settings.json. zeno owns the read-merge-write (see hook_install.py).
471
+ hook = subparsers.add_parser(
472
+ "hook",
473
+ help="Install/remove the Claude Code capture hook in ~/.claude/settings.json.",
474
+ )
475
+ hook_sub = hook.add_subparsers(dest="hook_command", required=True)
476
+
477
+ hook_install_p = hook_sub.add_parser(
478
+ "install", help="Write the capture hook into settings.json (idempotent, backed up)."
479
+ )
480
+ hook_install_p.add_argument(
481
+ "--settings-path",
482
+ default=None,
483
+ help="Override the settings.json path (default: ~/.claude/settings.json).",
484
+ )
485
+ hook_install_p.add_argument(
486
+ "--force",
487
+ action="store_true",
488
+ help="Replace an existing non-zeno statusLine (statusLine allows only one).",
489
+ )
490
+ hook_install_p.add_argument(
491
+ "--no-hud",
492
+ action="store_true",
493
+ help="Install only the capture hook; do not set the zeno-hud statusLine.",
494
+ )
495
+
496
+ hook_uninstall_p = hook_sub.add_parser(
497
+ "uninstall", help="Remove zeno's hook entries from settings.json."
498
+ )
499
+ hook_uninstall_p.add_argument("--settings-path", default=None)
500
+ hook_uninstall_p.add_argument(
501
+ "--restore",
502
+ action="store_true",
503
+ help="Restore settings.json from the latest zeno backup instead of surgical removal.",
504
+ )
505
+
506
+ hook_status_p = hook_sub.add_parser(
507
+ "status", help="Show which capture-hook events are installed."
508
+ )
509
+ hook_status_p.add_argument("--settings-path", default=None)
510
+
511
+ # `run` is dispatched by the fast-path passthrough in main(); registered here
512
+ # only so it shows up in `zeno hook --help`.
513
+ hook_sub.add_parser(
514
+ "run",
515
+ help="(internal) Exec the bundled capture hook; called by Claude Code per event.",
516
+ )
517
+
518
+ # The cognition HUD add-on: a single differentiated line (`zeno-hud-bar`) that
519
+ # stacks UNDER whatever popular HUD the user already runs (claude-hud /
520
+ # ccstatusline), plus the install/uninstall orchestrator that wires it in. See
521
+ # docs/HUD_ADDON.md. The `bar` subcommand is dispatched by a fast-path in main()
522
+ # (like `hook run`) for clean stdin + speed; registered here for `zeno hud --help`.
523
+ hud = subparsers.add_parser(
524
+ "hud",
525
+ help="Stack the zeno cognition line under your existing HUD (claude-hud / ccstatusline).",
526
+ )
527
+ hud_sub = hud.add_subparsers(dest="hud_command", required=True)
528
+ hud_sub.add_parser(
529
+ "bar",
530
+ help="(internal) Render the one cognition line from session JSON on stdin.",
531
+ )
532
+
533
+ hud_install_p = hud_sub.add_parser(
534
+ "install", help="Wire the zeno cognition line into your HUD (auto-detect by default)."
535
+ )
536
+ hud_install_p.add_argument(
537
+ "--target",
538
+ choices=["auto", "ccstatusline", "claude-hud"],
539
+ default="auto",
540
+ help="Which adapter to use (default: auto - ccstatusline if configured, else claude-hud).",
541
+ )
542
+ hud_install_p.add_argument(
543
+ "--dry-run",
544
+ action="store_true",
545
+ help="Print exactly what would change; write nothing.",
546
+ )
547
+ hud_install_p.add_argument(
548
+ "--force",
549
+ action="store_true",
550
+ help="Replace an existing non-zeno statusLine (claude-hud target only).",
551
+ )
552
+ hud_install_p.add_argument(
553
+ "--settings-path",
554
+ default=None,
555
+ help="Override settings.json (claude-hud target; default resolves symlinks to "
556
+ "settings.local.json).",
557
+ )
558
+ hud_install_p.add_argument(
559
+ "--ccstatusline-path",
560
+ default=None,
561
+ help="Override the ccstatusline settings.json path.",
562
+ )
563
+
564
+ hud_uninstall_p = hud_sub.add_parser(
565
+ "uninstall", help="Remove the zeno cognition line from both adapters (idempotent)."
566
+ )
567
+ hud_uninstall_p.add_argument("--settings-path", default=None)
568
+ hud_uninstall_p.add_argument("--ccstatusline-path", default=None)
569
+ hud_uninstall_p.add_argument(
570
+ "--restore",
571
+ action="store_true",
572
+ help="Restore each config from its latest zeno backup (byte-for-byte) instead of "
573
+ "surgical removal.",
574
+ )
575
+
576
+ hud_status_p = hud_sub.add_parser(
577
+ "status", help="Show which HUD is detected and whether the zeno line is wired in."
578
+ )
579
+ hud_status_p.add_argument("--settings-path", default=None)
580
+ hud_status_p.add_argument("--ccstatusline-path", default=None)
581
+
582
+ # Email DNS verification: surface SPF/DKIM/DMARC/MX/A status for the
583
+ # configured sending domain (zeno.center by default). Stdlib + dig only.
584
+ # See docs/EMAIL_DNS_SETUP.md for the records this checks against.
585
+ email = subparsers.add_parser("email", help="Email infrastructure helpers.")
586
+ email_sub = email.add_subparsers(dest="email_command", required=True)
587
+
588
+ check_dns = email_sub.add_parser(
589
+ "check-dns",
590
+ help="Run dig against the email DNS records for the sending domain.",
591
+ )
592
+ check_dns.add_argument(
593
+ "--domain",
594
+ default="zeno.center",
595
+ help="Sending domain (default: zeno.center).",
596
+ )
597
+ check_dns.add_argument(
598
+ "--dkim-selector",
599
+ default="bunmail",
600
+ help="DKIM selector (default: bunmail; matches bunmail's default).",
601
+ )
602
+ check_dns.add_argument(
603
+ "--mail-host",
604
+ default=None,
605
+ help="Mail hostname to check A + PTR for (default: mail.<domain>).",
606
+ )
607
+
608
+ # Outreach: rolodex-driven friendly intro mail via bunmail. Dry-run by
609
+ # default - actual sends require --confirm AND an interactive y/N. The
610
+ # CSV input is the rolodex repo's canonical_contacts_*.csv, the template
611
+ # lives in zeno_api.email_templates. See outreach.py for the safety
612
+ # contract.
613
+ outreach = subparsers.add_parser(
614
+ "outreach",
615
+ help="Rolodex-driven outreach via bunmail (dry-run by default).",
616
+ )
617
+ outreach_sub = outreach.add_subparsers(dest="outreach_command", required=True)
618
+
619
+ outreach_send = outreach_sub.add_parser(
620
+ "send",
621
+ help="Send (or dry-run) a templated outreach mail to filtered contacts.",
622
+ )
623
+ outreach_send.add_argument(
624
+ "--csv",
625
+ required=True,
626
+ help="Path to rolodex canonical_contacts_*.csv file.",
627
+ )
628
+ outreach_send.add_argument(
629
+ "--template",
630
+ required=True,
631
+ help="Template id from zeno_api.email_templates (e.g. outreach_intro_zeno).",
632
+ )
633
+ outreach_send.add_argument(
634
+ "--filter-tag",
635
+ default=None,
636
+ help="Only contacts with this tag (case-insensitive).",
637
+ )
638
+ outreach_send.add_argument(
639
+ "--max-sends",
640
+ type=int,
641
+ default=10,
642
+ help="Hard cap on number of mails per run (CLI default 10, max 100).",
643
+ )
644
+ outreach_send.add_argument(
645
+ "--exclude-recent-days",
646
+ type=int,
647
+ default=30,
648
+ help="Drop contacts mailed within this many days (default 30).",
649
+ )
650
+ outreach_send.add_argument(
651
+ "--confirm",
652
+ action="store_true",
653
+ help="Required to actually send. Without it, prints a dry-run plan.",
654
+ )
655
+
656
+ # Waitlist-driven first-100-customer interview invites. Same safety
657
+ # contract as `outreach send`: dry-run by default, --confirm + y/N
658
+ # to actually ship, hard cap on --max, ZENO_BUNMAIL_API_KEY +
659
+ # ZENO_BUNMAIL_BASE_URL required to even dry-run. Renders the
660
+ # customer_interview_invite template from zeno_api.email_templates.
661
+ invite = outreach_sub.add_parser(
662
+ "invite-interviews",
663
+ help=(
664
+ "Mail the customer_interview_invite template to waitlist signups (dry-run by default)."
665
+ ),
666
+ )
667
+ invite.add_argument(
668
+ "--csv",
669
+ required=True,
670
+ help=(
671
+ "Path to a waitlist export CSV. Required columns: email. "
672
+ "Optional: first_name, role, intent, why, signed_up_at."
673
+ ),
674
+ )
675
+ invite.add_argument(
676
+ "--max",
677
+ type=int,
678
+ default=10,
679
+ dest="max_invites",
680
+ help="Hard cap on invites per run (CLI default 10, max 50).",
681
+ )
682
+ invite.add_argument(
683
+ "--only-since-days",
684
+ type=int,
685
+ default=None,
686
+ help=(
687
+ "Only signups newer than N days (requires signed_up_at "
688
+ "column). Default: no recency filter."
689
+ ),
690
+ )
691
+ invite.add_argument(
692
+ "--confirm",
693
+ action="store_true",
694
+ help="Required to actually send. Without it, prints a dry-run plan.",
695
+ )
696
+
697
+ # zeno completion <shell>: emit a static completion script to stdout, the
698
+ # gh / kubectl / rustup pattern. Dependency-free (no argcomplete): the
699
+ # subcommand list is baked from TOP_LEVEL_COMMANDS at generation time.
700
+ completion = subparsers.add_parser(
701
+ "completion",
702
+ help="Print a shell-completion script (eval it or save to your completions dir).",
703
+ )
704
+ completion.add_argument(
705
+ "shell",
706
+ choices=["bash", "zsh", "fish"],
707
+ help="Target shell. e.g. 'zeno completion zsh > ~/.zsh/completions/_zeno'.",
708
+ )
709
+
710
+ return parser
711
+
712
+
713
+ def _project_id(project: str) -> str:
714
+ return str(uuid.uuid5(uuid.NAMESPACE_URL, project))
715
+
716
+
717
+ def _load_session_points(zeno: Zeno, project: str) -> list[SessionPoint]:
718
+ raw = asyncio.run(zeno.storage.get_session_points(_project_id(project)))
719
+ points: list[SessionPoint] = []
720
+ for row in raw:
721
+ ended_at = None
722
+ if row["ended_at"]:
723
+ ended_at = datetime.fromisoformat(row["ended_at"])
724
+ points.append(
725
+ SessionPoint(
726
+ session_id=row["session_id"],
727
+ n_agents_active=row["n_agents_active"],
728
+ composite_load=row["composite_load"],
729
+ output_quality=row["output_quality"],
730
+ ended_at=ended_at,
731
+ )
732
+ )
733
+ return points
734
+
735
+
736
+ def _post_rtlxs_response(
737
+ *,
738
+ base_url: str,
739
+ session_id: str,
740
+ payload: dict[str, object],
741
+ ) -> tuple[bool, str]:
742
+ """Best-effort POST of an RTLX-S response to the API.
743
+
744
+ Returns (success, note). Free-tier users without ZENO_API_TOKEN keep
745
+ the response locally - the API call is skipped, but the survey is
746
+ still logged in the local SQLite store. Network errors degrade
747
+ silently so the CLI never blocks on the server being down.
748
+ """
749
+ token = resolve_api_token()
750
+ if not token:
751
+ return False, "no ZENO_API_TOKEN set; response stored locally only"
752
+ client = ZenoApiClient(base_url=base_url, bearer_token=token, timeout_seconds=3.0)
753
+ try:
754
+ client._request(
755
+ "POST",
756
+ f"/v1/sessions/{session_id}/rtlxs",
757
+ json_body=payload,
758
+ )
759
+ return True, "POSTed to API"
760
+ except (ApiError, OSError) as exc:
761
+ return False, f"API POST skipped: {exc}"
762
+
763
+
764
+ def _run_survey(
765
+ *,
766
+ project: str,
767
+ harness: str,
768
+ no_probes: bool,
769
+ autonomy: str | None = None,
770
+ sced_blinded: bool = False,
771
+ sced_session_index: int | None = None,
772
+ ) -> int:
773
+ if sced_blinded and sced_session_index is None:
774
+ _print("error: --sced requires --sced-index <N> (the locked-schedule slot).")
775
+ return 2
776
+ zeno = Zeno(project=project)
777
+ if no_probes:
778
+ zeno.config.probes_enabled = False
779
+
780
+ with zeno.session(harness=harness) as session:
781
+ if no_probes:
782
+ session.prompt_load_probe(subscales=None, skipped=True)
783
+ _print("Probes disabled. Logged LoadProbe(skipped=True).")
784
+ zeno.shutdown()
785
+ return 0
786
+
787
+ # RTLX-S 5-item probe (research 1, 2026-06-07 PM3). Replaces the
788
+ # legacy 10-subscale NASA-TLX-S TUI. Non-tty contexts return None
789
+ # so background daemons / piped invocations skip the probe cleanly.
790
+ #
791
+ # Behavioral anchors for Stage 2a (research 3, 2026-06-10): resolve
792
+ # the most-recent session with actual agent activity (within the
793
+ # last 2 hours) and pull its interrupt + turn counts + estimated
794
+ # verification seconds. The survey opens a fresh session for its
795
+ # own bookkeeping, so the work-session telemetry has to be looked
796
+ # up explicitly. If no recent work session exists, anchors stay
797
+ # None and the rtlxs_responses row carries NULL for all three.
798
+ work_session_id = asyncio.run(
799
+ zeno.storage.find_recent_activity_session_id(window_minutes=120)
800
+ )
801
+ anchors: dict[str, int | None] = {
802
+ "agent_interrupts_count": None,
803
+ "agent_turns_count": None,
804
+ "verification_seconds": None,
805
+ }
806
+ if work_session_id is not None:
807
+ stats = asyncio.run(zeno.storage.compute_session_stats(work_session_id))
808
+ if stats.agent_turns_count > 0:
809
+ anchors = {
810
+ "agent_interrupts_count": stats.interrupt_count,
811
+ "agent_turns_count": stats.agent_turns_count,
812
+ "verification_seconds": stats.verification_seconds,
813
+ }
814
+ response = run_rtlxs_survey_tui(
815
+ session_id=session.session_id,
816
+ agent_interrupts_count=anchors["agent_interrupts_count"],
817
+ agent_turns_count=anchors["agent_turns_count"],
818
+ verification_seconds=anchors["verification_seconds"],
819
+ autonomy_condition=autonomy,
820
+ sced_blinded=sced_blinded,
821
+ sced_session_index=sced_session_index,
822
+ )
823
+ if response is None:
824
+ session.prompt_load_probe(subscales=None, skipped=True)
825
+ _print("Survey skipped.")
826
+ else:
827
+ # Persist locally first (preserves the legacy LoadProbe wire
828
+ # so the local curve fit still sees a probe row). The 5 raw
829
+ # values are stored under the load_probe subscales JSON column
830
+ # for now - the canonical wire format is the new
831
+ # rtlxs_responses table at the API.
832
+ local_subscales = {
833
+ "mental_demand": response.mental_demand,
834
+ "effort": response.effort,
835
+ "frustration": response.frustration,
836
+ "supervision_load": response.supervision_load,
837
+ "execution_load": response.execution_load,
838
+ }
839
+ session.prompt_load_probe(subscales=local_subscales, skipped=False)
840
+ ok, note = _post_rtlxs_response(
841
+ base_url=DEFAULT_API_BASE_URL,
842
+ session_id=session.session_id,
843
+ payload=response.to_payload(),
844
+ )
845
+ status_word = "submitted" if ok else "submitted locally"
846
+ _print(f"RTLX-S {status_word} for session {session.session_id} ({note}).")
847
+
848
+ zeno.shutdown()
849
+ return 0
850
+
851
+
852
+ def _run_report(*, project: str) -> int:
853
+ zeno = Zeno(project=project)
854
+ tier = _fetch_remote_tier(DEFAULT_API_BASE_URL)
855
+ session_id = asyncio.run(zeno.storage.latest_session_id(_project_id(project)))
856
+ if session_id is None:
857
+ _print("No sessions found yet.")
858
+ _print_tier_footer(tier)
859
+ zeno.shutdown()
860
+ return 0
861
+
862
+ stats = asyncio.run(zeno.storage.compute_session_stats(session_id))
863
+ points = _load_session_points(zeno, project)
864
+ curve = fit_babysitting_tax_curve(points)
865
+ if curve.calibration_in_progress or curve.optimal_n_agents is None:
866
+ _print(
867
+ f"Last session avg agents: {stats.avg_n_agents:.1f}. "
868
+ "Calibration in progress: need at least 10 sessions."
869
+ )
870
+ else:
871
+ _print(
872
+ f"You were a {int(round(stats.avg_n_agents))}-agent operator today; "
873
+ f"tax kicked in at agent {curve.optimal_n_agents}."
874
+ )
875
+ _print_tier_footer(tier)
876
+ zeno.shutdown()
877
+ return 0
878
+
879
+
880
+ def _run_curve(*, project: str, png: str) -> int:
881
+ zeno = Zeno(project=project)
882
+ points = _load_session_points(zeno, project)
883
+ curve = fit_babysitting_tax_curve(points)
884
+ _print(render_curve_ascii(curve))
885
+ if not curve.calibration_in_progress and curve.xs:
886
+ output = Path(png).expanduser()
887
+ save_curve_png(curve, output)
888
+ _print(f"Saved curve PNG to {output}.")
889
+ zeno.shutdown()
890
+ return 0
891
+
892
+
893
+ def _run_weekly(*, project: str) -> int:
894
+ zeno = Zeno(project=project)
895
+ points = _load_session_points(zeno, project)
896
+ summary = weekly_summary(points)
897
+ _print(render_weekly_table(summary))
898
+ zeno.shutdown()
899
+ return 0
900
+
901
+
902
+ def _fetch_remote_streak(base_url: str) -> StreakResult | None:
903
+ """Hit /v1/me/streak; return parsed StreakResult, or None on any failure.
904
+
905
+ Free-tier offline users won't have a token wired up - they get None and
906
+ the CLI falls back to the local SQLite computation. Network or auth
907
+ errors degrade silently for the same reason; the CLI should NEVER block
908
+ on the API to render `zeno status`.
909
+ """
910
+ token = resolve_api_token()
911
+ client = ZenoApiClient(base_url=base_url, bearer_token=token, timeout_seconds=2.0)
912
+ try:
913
+ result = client.me_streak()
914
+ except (ApiError, OSError):
915
+ return None
916
+ current = result.get("current")
917
+ longest = result.get("longest")
918
+ if not isinstance(current, int) or not isinstance(longest, int):
919
+ return None
920
+ last = result.get("last_response_at")
921
+ return StreakResult(
922
+ current=current,
923
+ longest=longest,
924
+ last_response_at=last if isinstance(last, str) else None,
925
+ )
926
+
927
+
928
+ def _fetch_remote_tier(base_url: str) -> str:
929
+ """Hit /v1/me with whatever token is in the env; return a tier string or
930
+ "offline" when the API isn't reachable or returns an auth error.
931
+
932
+ Free-tier users don't have a token wired up, so "offline" is the expected
933
+ common case. Treat it as a soft signal, not an error.
934
+ """
935
+ token = resolve_api_token()
936
+ client = ZenoApiClient(base_url=base_url, bearer_token=token, timeout_seconds=2.0)
937
+ try:
938
+ result = client.me()
939
+ tier = result.get("tier")
940
+ if isinstance(tier, str) and tier:
941
+ return tier
942
+ return "offline"
943
+ except (ApiError, OSError):
944
+ return "offline"
945
+
946
+
947
+ def _print_tier_footer(tier: str) -> None:
948
+ """Trailing line on report: name the current tier + what's locked behind paid."""
949
+ colors = _colors_enabled()
950
+ if tier in {"free", "offline"}:
951
+ label = _color("free tier", "33", enabled=colors)
952
+ _print(
953
+ f"On {label}. Paid tiers unlock cloud sync, AI recommendations, "
954
+ "and the weekly email digest. Run 'zeno status' for details."
955
+ )
956
+ else:
957
+ label = _color(tier, "32", enabled=colors)
958
+ _print(f"On {label} tier. All Pro features active.")
959
+
960
+
961
+ def _run_status(*, project: str, base_url: str) -> int:
962
+ """One-screen summary. Hits local SQLite + a 2s remote probe.
963
+
964
+ Layout (free-tier first-class):
965
+ Tier: <free|pro|offline>
966
+ Last session: <ISO timestamp or "none">
967
+ Sessions logged: <N>
968
+ Tax curve: <optimal-N or "calibrating (N/10)">
969
+ Survey streak: <N day(s)> (longest: <M>) <- research 1 PM3
970
+ [warning if no response since today's 03:00 cutoff]
971
+ Tip: <upgrade nudge for free, /usage pointer for paid>
972
+
973
+ Streak source-of-truth order: remote API (paid users, online) ->
974
+ local SQLite (free + offline). Both share the algorithm in
975
+ zeno_core.streak so the displayed value is identical when the user
976
+ has been online and synced.
977
+ """
978
+ colors = _colors_enabled()
979
+ zeno = Zeno(project=project)
980
+ project_uuid = _project_id(project)
981
+
982
+ tier = _fetch_remote_tier(base_url)
983
+ session_id = asyncio.run(zeno.storage.latest_session_id(project_uuid))
984
+ last_iso = "none"
985
+ if session_id is not None:
986
+ row = asyncio.run(
987
+ zeno.storage._fetchone(
988
+ "SELECT end_at FROM sessions WHERE id = ?",
989
+ (session_id,),
990
+ )
991
+ )
992
+ if row is not None and row[0]:
993
+ last_iso = str(row[0])
994
+
995
+ count_row = asyncio.run(
996
+ zeno.storage._fetchone(
997
+ "SELECT COUNT(*) FROM sessions WHERE project_id = ?",
998
+ (project_uuid,),
999
+ )
1000
+ )
1001
+ session_count = int(count_row[0]) if count_row is not None else 0
1002
+
1003
+ points = _load_session_points(zeno, project)
1004
+ curve = fit_babysitting_tax_curve(points)
1005
+ if curve.calibration_in_progress or curve.optimal_n_agents is None:
1006
+ n_points = len(points)
1007
+ tax_label = _color(f"calibrating ({n_points}/10)", "33", enabled=colors)
1008
+ else:
1009
+ tax_label = _color(f"optimal-N = {curve.optimal_n_agents}", "32", enabled=colors)
1010
+
1011
+ # Streak: prefer remote (single source of truth across machines), fall
1012
+ # back to local SQLite when offline. The two should match once a user is
1013
+ # synced; for free-tier offline users only the local view exists.
1014
+ streak = _fetch_remote_streak(base_url)
1015
+ if streak is None:
1016
+ streak = asyncio.run(read_streak_from_local_storage(zeno.storage, project_uuid))
1017
+
1018
+ # Compute today's "have you logged today" warning from local probe
1019
+ # rows (the API endpoint doesn't yet return per-day flags). Free-tier
1020
+ # users who never sync see exactly the same warning as paid users.
1021
+ local_probe_rows = asyncio.run(
1022
+ zeno.storage._fetchall(
1023
+ """
1024
+ SELECT lp.responded_at, lp.prompted_at
1025
+ FROM load_probes lp
1026
+ JOIN sessions s ON s.id = lp.session_id
1027
+ WHERE s.project_id = ? AND lp.skipped = 0
1028
+ """,
1029
+ (project_uuid,),
1030
+ )
1031
+ )
1032
+ local_timestamps: list[datetime] = []
1033
+ for r in local_probe_rows:
1034
+ raw = r[0] or r[1]
1035
+ if not raw:
1036
+ continue
1037
+ try:
1038
+ local_timestamps.append(datetime.fromisoformat(str(raw)))
1039
+ except ValueError:
1040
+ continue
1041
+ logged_today = has_logged_today(local_timestamps, today=datetime.now().date())
1042
+
1043
+ if tier == "offline":
1044
+ tier_label = _color("offline", "31", enabled=colors)
1045
+ elif tier == "free":
1046
+ tier_label = _color("free", "33", enabled=colors)
1047
+ else:
1048
+ tier_label = _color(tier, "32", enabled=colors)
1049
+
1050
+ # Streak coloring: green for an active streak, yellow for none. We
1051
+ # intentionally never red the streak even if broken - the longest still
1052
+ # shows a personal best to beat (BJ Fogg "celebrate small wins").
1053
+ if streak.current >= 1:
1054
+ streak_value = _color(
1055
+ f"{streak.current} day{'s' if streak.current != 1 else ''}",
1056
+ "32",
1057
+ enabled=colors,
1058
+ )
1059
+ else:
1060
+ streak_value = _color("0 days", "33", enabled=colors)
1061
+ streak_label = f"{streak_value} (longest: {streak.longest})"
1062
+
1063
+ # Today's survey response: explicit yes/no so the operator sees the
1064
+ # dogfood-discipline state in the status snapshot, not just a warning
1065
+ # buried under the streak. Same data source as `logged_today`.
1066
+ if logged_today:
1067
+ survey_today_label = _color("yes", "32", enabled=colors)
1068
+ else:
1069
+ survey_today_label = _color("no", "33", enabled=colors)
1070
+
1071
+ # Cognitive-state row reads the ~/.zeno/cognitive-state.json mirror
1072
+ # written by the API (research 2 DPSC-CP Phase 0). Free-tier users who
1073
+ # never hit /v1/cognitive-state won't have a cache file and we render
1074
+ # "unknown" - that's the explicit UNKNOWN state in the DPSC-CP enum
1075
+ # and the right answer rather than hiding the row.
1076
+ cog = _read_cognitive_state_cache(DEFAULT_COGNITIVE_STATE_CACHE)
1077
+ if cog is None:
1078
+ cog_label = _color("unknown", "33", enabled=colors) + " (no cache)"
1079
+ else:
1080
+ state, age = cog
1081
+ color_for_state = {
1082
+ "FRESH": "32",
1083
+ "NEUTRAL": "36",
1084
+ "DEGRADED": "33",
1085
+ "UNKNOWN": "33",
1086
+ }
1087
+ cog_label = _color(state, color_for_state.get(state, "0"), enabled=colors) + f" ({age})"
1088
+
1089
+ _print(f"Tier: {tier_label}")
1090
+ _print(f"Last session: {last_iso}")
1091
+ _print(f"Sessions logged: {session_count}")
1092
+ _print(f"Tax curve: {tax_label}")
1093
+ _print(f"Survey streak: {streak_label}")
1094
+ _print(f"Survey today: {survey_today_label}")
1095
+ _print(f"Cognitive: {cog_label}")
1096
+ if not logged_today and streak.current >= 1:
1097
+ # Streak is alive but today is empty - that's the surgical nudge.
1098
+ # Phrasing follows the "don't break the chain" frame; the action is
1099
+ # one command away (`zeno survey`).
1100
+ _print(
1101
+ _color(
1102
+ " You have not logged today. Run 'zeno survey' to keep the streak.",
1103
+ "33",
1104
+ enabled=colors,
1105
+ )
1106
+ )
1107
+ elif not logged_today and streak.current == 0 and streak.longest >= 1:
1108
+ _print(
1109
+ _color(
1110
+ " Streak broken. Run 'zeno survey' to start a "
1111
+ f"new run at your personal best of {streak.longest}.",
1112
+ "33",
1113
+ enabled=colors,
1114
+ )
1115
+ )
1116
+ if tier in {"free", "offline"}:
1117
+ _print(
1118
+ "Tip: Run 'zeno billing recommend-tier --engineers <N>' to size up to Pro."
1119
+ )
1120
+ else:
1121
+ _print("Tip: Open the /usage dashboard for credit + event trends.")
1122
+
1123
+ zeno.shutdown()
1124
+ return 0
1125
+
1126
+
1127
+ def _run_tips() -> int:
1128
+ """Print today's tip. Rotation = ordinal day-of-year mod len(TIPS)."""
1129
+ index = date.today().toordinal() % len(TIPS)
1130
+ colors = _colors_enabled()
1131
+ header = _color("Today's tip", "36", enabled=colors)
1132
+ _print(f"{header}: {TIPS[index]}")
1133
+ return 0
1134
+
1135
+
1136
+ def _run_session_intel_passthrough(module_name: str, argv: list[str]) -> int:
1137
+ """Delegate to a zeno_session_intel sub-CLI (ingest / analytics), lazy-imported.
1138
+
1139
+ Folds the standalone `zeno-ingest` / `zeno-usage` entry points under the main
1140
+ CLI as `zeno ingest` / `zeno usage`. The import is deferred so it costs nothing
1141
+ on the hot path and degrades cleanly: zeno-session-intel is a workspace package,
1142
+ NOT bundled into the zeno-cli wheel, so a bare wheel install prints a hint
1143
+ instead of a traceback. The sub-CLI parses its own args (so --tools, --db,
1144
+ --write-live, --search, --limit all work unchanged, and the live-DB write
1145
+ guard inside ingest.main stays in force).
1146
+ """
1147
+ import importlib # noqa: PLC0415
1148
+
1149
+ try:
1150
+ mod = importlib.import_module(module_name)
1151
+ except ImportError:
1152
+ _print(
1153
+ "This command needs the zeno-session-intel package, which isn't bundled "
1154
+ "in this install.\nRun it from the zeno workspace, or install "
1155
+ "zeno-session-intel alongside the CLI."
1156
+ )
1157
+ return 1
1158
+ return int(mod.main(argv))
1159
+
1160
+
1161
+ def _parse_metadata_pairs(pairs: list[str] | None) -> dict[str, str]:
1162
+ """Parse repeated --metadata KEY=VALUE flags into a dict.
1163
+
1164
+ Raises ValueError on a malformed pair so the caller can surface a clean
1165
+ message instead of a traceback. The API expects dict[str, str], so values
1166
+ are kept as strings (the meter records what the caller asserts).
1167
+ """
1168
+ out: dict[str, str] = {}
1169
+ for raw in pairs or []:
1170
+ if "=" not in raw:
1171
+ raise ValueError(f"metadata must be KEY=VALUE, got: {raw!r}")
1172
+ key, value = raw.split("=", 1)
1173
+ if not key:
1174
+ raise ValueError(f"metadata key must be non-empty, got: {raw!r}")
1175
+ out[key] = value
1176
+ return out
1177
+
1178
+
1179
+ def _run_metrics_emit(
1180
+ *, event: str, quantity: int, metadata: list[str] | None, base_url: str
1181
+ ) -> int:
1182
+ """Emit one usage event to the Stage 1.5 meter and print the response."""
1183
+ try:
1184
+ event_metadata = _parse_metadata_pairs(metadata)
1185
+ except ValueError as exc:
1186
+ _print(f"Error: {exc}")
1187
+ return 2
1188
+
1189
+ payload: dict[str, object] = {"event_type": event, "credits": quantity}
1190
+ if event_metadata:
1191
+ payload["event_metadata"] = event_metadata
1192
+
1193
+ client = ZenoApiClient(base_url=base_url, bearer_token=resolve_api_token())
1194
+ try:
1195
+ result = client.usage_emit(payload=payload)
1196
+ except ApiError as exc:
1197
+ _print(f"Error emitting usage event: {exc}")
1198
+ return 1
1199
+ _print(json.dumps(result, indent=2))
1200
+ return 0
1201
+
1202
+
1203
+ def _run_billing_recommend_tier(*, engineers: int, base_url: str) -> int:
1204
+ client = ZenoApiClient(base_url=base_url)
1205
+ result = client.billing_recommend_tier(engineers=str(engineers))
1206
+ _print(json.dumps(result, indent=2))
1207
+ return 0
1208
+
1209
+
1210
+ def _run_billing_preview_tier(*, tier: str, period: str, base_url: str) -> int:
1211
+ client = ZenoApiClient(base_url=base_url)
1212
+ result = client.billing_preview_checkout(tier=tier, billing_period=period)
1213
+ _print(json.dumps(result, indent=2))
1214
+ return 0
1215
+
1216
+
1217
+ def _run_version() -> int:
1218
+ """Print the version banner. Same output `zeno --version` produces, but
1219
+ reachable via the subcommand shape for muscle-memory parity with
1220
+ kubectl/docker/doctl. Always returns 0."""
1221
+ _print(f"zeno {version_string()}")
1222
+ return 0
1223
+
1224
+
1225
+ def _run_login(*, authorize_url: str, open_browser: bool) -> int:
1226
+ """Run the browser + loopback login flow and store the Clerk JWT.
1227
+
1228
+ On success the token lands in the OS keyring (via zeno_sdk.auth) and the
1229
+ CLI echoes the signed-in identity decoded (UNVERIFIED) from the JWT - the
1230
+ API is the source of truth for authz; this is a local convenience print.
1231
+ """
1232
+ from . import login as login_mod # noqa: PLC0415
1233
+
1234
+ try:
1235
+ token = login_mod.run_loopback_login(authorize_url, open_browser=open_browser)
1236
+ except login_mod.LoginError as exc:
1237
+ _print(f"Login failed: {exc}")
1238
+ return 1
1239
+ except OSError as exc:
1240
+ _print(f"Login failed: could not start the local callback server: {exc}")
1241
+ return 1
1242
+
1243
+ login_mod.store_token(token)
1244
+ claims = login_mod._decode_jwt_unverified(token)
1245
+ who = claims.get("email") or claims.get("sub") or claims.get("user_id")
1246
+ if who:
1247
+ _print(f"Logged in as {who}. Token stored in the OS keyring.")
1248
+ else:
1249
+ _print("Logged in. Token stored in the OS keyring.")
1250
+ return 0
1251
+
1252
+
1253
+ def _run_logout() -> int:
1254
+ """Clear the stored Clerk token from the OS keyring (idempotent)."""
1255
+ from . import login as login_mod # noqa: PLC0415
1256
+
1257
+ login_mod.clear_token()
1258
+ _print("Logged out. Cleared the stored Clerk token from the OS keyring.")
1259
+ return 0
1260
+
1261
+
1262
+ def _run_whoami(*, base_url: str) -> int:
1263
+ """Resolve the stored token and print the identity + tier the API sees.
1264
+
1265
+ Hits GET /v1/me with the resolved bearer token. Returns 1 (with a clear
1266
+ message) when no token is stored or the token is rejected (401), so this
1267
+ doubles as a quick "is my login still good?" check.
1268
+ """
1269
+ token = resolve_api_token()
1270
+ if not token:
1271
+ _print("Not logged in. Run 'zeno login' to authenticate.")
1272
+ return 1
1273
+ client = ZenoApiClient(base_url=base_url, bearer_token=token, timeout_seconds=5.0)
1274
+ try:
1275
+ result = client.me()
1276
+ except ApiError as exc:
1277
+ if exc.status_code == 401:
1278
+ _print("Stored token was rejected (401). Run 'zeno login' to re-authenticate.")
1279
+ return 1
1280
+ _print(f"Error calling /v1/me: {exc}")
1281
+ return 1
1282
+ except OSError as exc:
1283
+ _print(f"Could not reach the API at {base_url}: {exc}")
1284
+ return 1
1285
+ user = result.get("user_id") or result.get("sub") or "<unknown>"
1286
+ tier = result.get("tier") or "<unknown>"
1287
+ _print(f"Logged in as {user} (tier: {tier}).")
1288
+ return 0
1289
+
1290
+
1291
+ def _run_doctor(*, base_url: str) -> int:
1292
+ """Render the doctor checks as a left-aligned table + summary line.
1293
+
1294
+ Each row is `<label-padded> <colored-status> <detail>`. The summary
1295
+ line counts PASS/WARN/FAIL totals so the operator sees the shape
1296
+ without scanning. Exit code mirrors `doctor_exit_code` from
1297
+ doctor.py: 0 unless any FAIL row appears.
1298
+ """
1299
+ colors = _colors_enabled()
1300
+ color_for = {STATUS_PASS: "32", STATUS_WARN: "33", STATUS_FAIL: "31"}
1301
+
1302
+ checks = run_doctor(
1303
+ base_url=base_url,
1304
+ db_path=DEFAULT_DB_PATH,
1305
+ cache_path=DEFAULT_COGNITIVE_STATE_CACHE,
1306
+ )
1307
+ label_width = max((len(c.label) for c in checks), default=20)
1308
+
1309
+ _print(f"zeno doctor (base url: {base_url})")
1310
+ _print("")
1311
+ for check in checks:
1312
+ colored = _color(check.status, color_for.get(check.status, "0"), enabled=colors)
1313
+ _print(f" {check.label.ljust(label_width)} {colored} {check.detail}")
1314
+ _print("")
1315
+
1316
+ n_pass = sum(1 for c in checks if c.status == STATUS_PASS)
1317
+ n_warn = sum(1 for c in checks if c.status == STATUS_WARN)
1318
+ n_fail = sum(1 for c in checks if c.status == STATUS_FAIL)
1319
+ summary = (
1320
+ f"{_color(f'{n_pass} pass', '32', enabled=colors)}, "
1321
+ f"{_color(f'{n_warn} warn', '33', enabled=colors)}, "
1322
+ f"{_color(f'{n_fail} fail', '31', enabled=colors)}"
1323
+ )
1324
+ _print(f"Summary: {summary}")
1325
+ return doctor_exit_code(checks)
1326
+
1327
+
1328
+ def _run_onboard(
1329
+ *,
1330
+ base_url: str,
1331
+ dry_run: bool,
1332
+ force: bool,
1333
+ settings_path: str | None,
1334
+ ccstatusline_path: str | None,
1335
+ ) -> int:
1336
+ """One-command setup: capture hook + HUD line + doctor, then a BLUF summary.
1337
+
1338
+ Delegates the orchestration to onboard.run_onboard (which composes the
1339
+ existing installers + doctor) and renders the result here. Exit code is 0
1340
+ unless doctor found a FAIL row - matching `zeno doctor`'s contract, so an
1341
+ off-tailnet local-only onboard still exits 0.
1342
+ """
1343
+ from .onboard import run_onboard # noqa: PLC0415
1344
+
1345
+ colors = _colors_enabled()
1346
+ res = run_onboard(
1347
+ base_url=base_url,
1348
+ settings_path=settings_path,
1349
+ ccstatusline_path=ccstatusline_path,
1350
+ dry_run=dry_run,
1351
+ force=force,
1352
+ )
1353
+
1354
+ tag = "(dry-run) " if res.dry_run else ""
1355
+ header = _color(f"{tag}zeno onboard", "36", enabled=colors)
1356
+ _print(f"{header} - wiring you up for AI-supervision capture.")
1357
+ _print("")
1358
+ for step in res.steps:
1359
+ _print(f" {_color(step.name, '36', enabled=colors)}: {step.status}")
1360
+ for note in step.notes:
1361
+ _print(f" {note}")
1362
+ _print("")
1363
+
1364
+ if res.dry_run:
1365
+ _print(
1366
+ _color(
1367
+ "Dry-run: nothing was written. Re-run without --dry-run to apply.",
1368
+ "33",
1369
+ enabled=colors,
1370
+ )
1371
+ )
1372
+ return 0
1373
+
1374
+ # BLUF summary line: verdict first.
1375
+ if res.doctor_ran:
1376
+ summary = (
1377
+ f"{_color(f'{res.doctor_pass} pass', '32', enabled=colors)}, "
1378
+ f"{_color(f'{res.doctor_warn} warn', '33', enabled=colors)}, "
1379
+ f"{_color(f'{res.doctor_fail} fail', '31', enabled=colors)}"
1380
+ )
1381
+ _print(f"Doctor: {summary}")
1382
+
1383
+ if res.doctor_fail:
1384
+ _print(
1385
+ _color(
1386
+ "Setup wired, but doctor found a hard failure. Run 'zeno doctor' for the "
1387
+ "row-by-row detail and fix it before relying on capture.",
1388
+ "31",
1389
+ enabled=colors,
1390
+ )
1391
+ )
1392
+ return 1
1393
+
1394
+ ok = _color("You're set up.", "32", enabled=colors)
1395
+ _print(f"{ok} Start a NEW Claude Code session so capture begins, then code as normal.")
1396
+ _print(
1397
+ " Next: run 'zeno survey' after a session to calibrate your curve; "
1398
+ "'zeno status' anytime."
1399
+ )
1400
+ _print(" Undo with: zeno hook uninstall && zeno hud uninstall")
1401
+ return 0
1402
+
1403
+
1404
+ def _read_cognitive_state_cache(cache_path: Path) -> tuple[str, str] | None:
1405
+ """Read ~/.zeno/cognitive-state.json and return (state, age-string), or
1406
+ None if the file is missing or unreadable.
1407
+
1408
+ Phase 0 cache is a JSON document written by the API on every authed
1409
+ GET /v1/cognitive-state for the local operator. `state` mirrors the
1410
+ DPSC-CP state enum (FRESH / NEUTRAL / DEGRADED / UNKNOWN). The age
1411
+ string ("3m ago", "stale 47m") is computed from the file mtime, not
1412
+ the payload's as_of - mtime is cheap and matches the freshness model
1413
+ `zeno doctor` already uses.
1414
+ """
1415
+ if not cache_path.exists():
1416
+ return None
1417
+ try:
1418
+ payload = json.loads(cache_path.read_text(encoding="utf-8"))
1419
+ except (OSError, ValueError):
1420
+ return None
1421
+ state = payload.get("state")
1422
+ if not isinstance(state, str):
1423
+ return None
1424
+ try:
1425
+ mtime = datetime.fromtimestamp(cache_path.stat().st_mtime)
1426
+ except OSError:
1427
+ return state, "age unknown"
1428
+ age = datetime.now() - mtime
1429
+ minutes = int(age.total_seconds() // 60)
1430
+ if minutes < 1:
1431
+ return state, "just now"
1432
+ return state, f"{minutes}m ago"
1433
+
1434
+
1435
+ def _dig(record_type: str, name: str) -> tuple[int, str]:
1436
+ """Run `dig +short <type> <name>` and return (exit_code, stripped_stdout).
1437
+
1438
+ Exit code reflects dig's own status, not record presence. Empty stdout
1439
+ means no record published yet.
1440
+ """
1441
+ proc = subprocess.run(
1442
+ ["dig", "+short", record_type, name],
1443
+ capture_output=True,
1444
+ text=True,
1445
+ timeout=10,
1446
+ )
1447
+ return proc.returncode, proc.stdout.strip()
1448
+
1449
+
1450
+ def _dig_reverse(ip: str) -> tuple[int, str]:
1451
+ """Run `dig -x <ip> +short` (PTR lookup) and return (exit_code, stripped_stdout)."""
1452
+ proc = subprocess.run(
1453
+ ["dig", "-x", ip, "+short"],
1454
+ capture_output=True,
1455
+ text=True,
1456
+ timeout=10,
1457
+ )
1458
+ return proc.returncode, proc.stdout.strip()
1459
+
1460
+
1461
+ def _grade_email_dns(
1462
+ *,
1463
+ domain: str,
1464
+ dkim_selector: str,
1465
+ mail_host: str,
1466
+ ) -> tuple[list[tuple[str, str, str]], bool]:
1467
+ """Run all five email-DNS checks and return (rows, all_pass).
1468
+
1469
+ Each row is (record_label, status, detail). status is one of:
1470
+ PASS - record present and looks well-formed
1471
+ WARN - record present but suspicious (multiple SPF, p=none DMARC, etc.)
1472
+ FAIL - record missing
1473
+ """
1474
+ rows: list[tuple[str, str, str]] = []
1475
+
1476
+ # A record for mail host
1477
+ _, a_out = _dig("A", mail_host)
1478
+ if a_out:
1479
+ rows.append(("A " + mail_host, "PASS", a_out.splitlines()[0]))
1480
+ mail_ip = a_out.splitlines()[0]
1481
+ else:
1482
+ rows.append(("A " + mail_host, "FAIL", "no A record"))
1483
+ mail_ip = ""
1484
+
1485
+ # MX record
1486
+ _, mx_out = _dig("MX", domain)
1487
+ if mx_out:
1488
+ rows.append(("MX " + domain, "PASS", mx_out.splitlines()[0]))
1489
+ else:
1490
+ rows.append(("MX " + domain, "WARN", "no MX (OK if inbound disabled)"))
1491
+
1492
+ # SPF TXT (must have exactly one record containing v=spf1)
1493
+ _, txt_out = _dig("TXT", domain)
1494
+ spf_records = [line for line in txt_out.splitlines() if "v=spf1" in line.lower()]
1495
+ if len(spf_records) == 0:
1496
+ rows.append(("SPF " + domain, "FAIL", "no v=spf1 TXT record"))
1497
+ elif len(spf_records) > 1:
1498
+ rows.append(
1499
+ (
1500
+ "SPF " + domain,
1501
+ "FAIL",
1502
+ f"{len(spf_records)} SPF records (must be exactly 1)",
1503
+ )
1504
+ )
1505
+ else:
1506
+ rows.append(("SPF " + domain, "PASS", spf_records[0][:80]))
1507
+
1508
+ # DKIM TXT at <selector>._domainkey.<domain>
1509
+ dkim_name = f"{dkim_selector}._domainkey.{domain}"
1510
+ _, dkim_out = _dig("TXT", dkim_name)
1511
+ if dkim_out and "v=DKIM1" in dkim_out:
1512
+ rows.append(("DKIM " + dkim_name, "PASS", dkim_out[:60] + "..."))
1513
+ elif dkim_out:
1514
+ rows.append(("DKIM " + dkim_name, "WARN", "TXT present but no v=DKIM1"))
1515
+ else:
1516
+ rows.append(("DKIM " + dkim_name, "FAIL", "no DKIM TXT record"))
1517
+
1518
+ # DMARC TXT at _dmarc.<domain>
1519
+ dmarc_name = f"_dmarc.{domain}"
1520
+ _, dmarc_out = _dig("TXT", dmarc_name)
1521
+ if dmarc_out and "v=DMARC1" in dmarc_out:
1522
+ if "p=none" in dmarc_out.lower():
1523
+ rows.append(("DMARC " + dmarc_name, "WARN", "p=none (monitor-only; OK while warming)"))
1524
+ else:
1525
+ rows.append(("DMARC " + dmarc_name, "PASS", dmarc_out[:80]))
1526
+ else:
1527
+ rows.append(("DMARC " + dmarc_name, "FAIL", "no DMARC TXT record"))
1528
+
1529
+ # Reverse DNS for the sending IP - only if A record resolved
1530
+ if mail_ip:
1531
+ _, ptr_out = _dig_reverse(mail_ip)
1532
+ if ptr_out and mail_host in ptr_out:
1533
+ rows.append(("PTR " + mail_ip, "PASS", ptr_out.splitlines()[0]))
1534
+ elif ptr_out:
1535
+ rows.append(
1536
+ ("PTR " + mail_ip, "WARN", f"PTR is {ptr_out.splitlines()[0]} (want {mail_host}.)")
1537
+ )
1538
+ else:
1539
+ rows.append(("PTR " + mail_ip, "FAIL", "no PTR (request via AWS support)"))
1540
+ else:
1541
+ rows.append(("PTR <unknown>", "FAIL", "skipped (no A record to lookup)"))
1542
+
1543
+ all_pass = all(status == "PASS" for _, status, _ in rows)
1544
+ return rows, all_pass
1545
+
1546
+
1547
+ def _run_email_check_dns(
1548
+ *,
1549
+ domain: str,
1550
+ dkim_selector: str,
1551
+ mail_host: str | None,
1552
+ ) -> int:
1553
+ """Verify SPF/DKIM/DMARC/MX/A/PTR for the configured sending domain."""
1554
+ if shutil.which("dig") is None:
1555
+ _print("dig not found in PATH. Install bind-tools / dnsutils first.")
1556
+ return 2
1557
+
1558
+ mail_host = mail_host or f"mail.{domain}"
1559
+ rows, all_pass = _grade_email_dns(
1560
+ domain=domain,
1561
+ dkim_selector=dkim_selector,
1562
+ mail_host=mail_host,
1563
+ )
1564
+
1565
+ colors = _colors_enabled()
1566
+ color_for = {"PASS": "32", "WARN": "33", "FAIL": "31"}
1567
+ label_width = max((len(label) for label, _, _ in rows), default=20)
1568
+ _print(f"Email DNS check for {domain} (mail host: {mail_host})")
1569
+ _print("")
1570
+ for label, status, detail in rows:
1571
+ colored_status = _color(status, color_for.get(status, "0"), enabled=colors)
1572
+ _print(f" {label.ljust(label_width)} {colored_status} {detail}")
1573
+ _print("")
1574
+ if all_pass:
1575
+ _print(_color("All records pass. Ready to send.", "32", enabled=colors))
1576
+ return 0
1577
+
1578
+ _print(
1579
+ _color(
1580
+ "Some records failing or in warn state. See docs/EMAIL_DNS_SETUP.md.",
1581
+ "33",
1582
+ enabled=colors,
1583
+ )
1584
+ )
1585
+ return 1
1586
+
1587
+
1588
+ def _run_outreach_send(
1589
+ *,
1590
+ csv_path: str,
1591
+ template_id: str,
1592
+ filter_tag: str | None,
1593
+ max_sends: int,
1594
+ exclude_recent_days: int,
1595
+ confirm: bool,
1596
+ ) -> int:
1597
+ """Dry-run-first outreach. See apps/cli/src/zeno_cli/outreach.py docstring.
1598
+
1599
+ Layout:
1600
+ 1. Validate env (ZENO_BUNMAIL_API_KEY required to even dry-run -
1601
+ we want callers to confirm bunmail config before they look at
1602
+ a long contact list).
1603
+ 2. Load CSV + suppression, filter, sort.
1604
+ 3. Print dry-run plan (first 5 + full first-mail render).
1605
+ 4. If --confirm: interactive y/N -> iterate sends, sleep, log.
1606
+ 5. Always write outreach-runs/YYYY-MM-DD-HHMM.csv as audit.
1607
+ """
1608
+ # Imports inside the function so plain `zeno --help` doesn't pay the
1609
+ # cost of loading outreach.py + the template registry. The CLI loads
1610
+ # fast for the common cases (status, tips, survey).
1611
+ from zeno_api.email_templates import TEMPLATES # noqa: PLC0415
1612
+
1613
+ from .outreach import ( # noqa: PLC0415
1614
+ DEFAULT_SEND_PAUSE_SECS,
1615
+ HARD_MAX_SENDS,
1616
+ OutreachError,
1617
+ filter_contacts,
1618
+ first_name,
1619
+ load_contacts_csv,
1620
+ load_suppression_list,
1621
+ render_personal_hook,
1622
+ send_one,
1623
+ write_run_log,
1624
+ )
1625
+
1626
+ colors = _colors_enabled()
1627
+
1628
+ api_key = os.environ.get("ZENO_BUNMAIL_API_KEY", "").strip()
1629
+ if not api_key:
1630
+ _print(
1631
+ _color(
1632
+ "ZENO_BUNMAIL_API_KEY is not set. Refusing to run.",
1633
+ "31",
1634
+ enabled=colors,
1635
+ )
1636
+ )
1637
+ return 2
1638
+
1639
+ if max_sends <= 0:
1640
+ _print("--max-sends must be positive.")
1641
+ return 2
1642
+ if max_sends > HARD_MAX_SENDS:
1643
+ _print(f"--max-sends {max_sends} exceeds hard cap of {HARD_MAX_SENDS}. Refusing to run.")
1644
+ return 2
1645
+
1646
+ bunmail_base_url = os.environ.get("ZENO_BUNMAIL_BASE_URL", "").strip()
1647
+ if not bunmail_base_url:
1648
+ _print(
1649
+ _color(
1650
+ "ZENO_BUNMAIL_BASE_URL is not set. Refusing to run.",
1651
+ "31",
1652
+ enabled=colors,
1653
+ )
1654
+ )
1655
+ return 2
1656
+
1657
+ sender = os.environ.get(
1658
+ "ZENO_OUTREACH_SENDER",
1659
+ "Mar <mar@zeno.center>",
1660
+ )
1661
+
1662
+ if template_id not in TEMPLATES:
1663
+ _print(
1664
+ _color(
1665
+ f"Unknown template_id: {template_id!r}. Available: {sorted(TEMPLATES)}",
1666
+ "31",
1667
+ enabled=colors,
1668
+ )
1669
+ )
1670
+ return 2
1671
+
1672
+ csv_path_obj = Path(csv_path).expanduser()
1673
+ if not csv_path_obj.exists():
1674
+ _print(_color(f"CSV not found: {csv_path_obj}", "31", enabled=colors))
1675
+ return 2
1676
+
1677
+ # Suppression list lives in apps/cli/data/ so it ships with the CLI
1678
+ # source and the operator can edit + commit it. Path is relative to
1679
+ # this file's parents - apps/cli/src/zeno_cli/main.py -> apps/cli/
1680
+ suppression_path = Path(__file__).resolve().parents[2] / "data" / "outreach_suppression.txt"
1681
+ suppression = load_suppression_list(suppression_path)
1682
+
1683
+ all_contacts = load_contacts_csv(csv_path_obj)
1684
+ filtered = filter_contacts(
1685
+ all_contacts,
1686
+ filter_tag=filter_tag,
1687
+ exclude_recent_days=exclude_recent_days,
1688
+ suppression=suppression,
1689
+ )
1690
+
1691
+ capped = filtered[:max_sends]
1692
+ n = len(capped)
1693
+
1694
+ _print(
1695
+ _color(
1696
+ f"Loaded {len(all_contacts)} contacts, "
1697
+ f"{len(filtered)} pass filters, {n} after --max-sends.",
1698
+ "36",
1699
+ enabled=colors,
1700
+ )
1701
+ )
1702
+ _print("")
1703
+ _print(f"Would send to {n} contacts:")
1704
+ for c in capped[:5]:
1705
+ _print(
1706
+ f" - {c.full_name} <{c.primary_email}> "
1707
+ f"[{c.tier or 'no-tier'}, score={c.relationship_score}]"
1708
+ )
1709
+ if n > 5:
1710
+ _print(f" ... and {n - 5} more.")
1711
+ _print("")
1712
+
1713
+ if n == 0:
1714
+ _print("Nothing to send. Exiting.")
1715
+ return 0
1716
+
1717
+ # Render the first mail in FULL so the operator sees what's about
1718
+ # to ship before they type "y". This is the most important guard
1719
+ # against a bad template / bad hook.
1720
+ first = capped[0]
1721
+ variables = {
1722
+ "first_name": first_name(first),
1723
+ "personal_hook": render_personal_hook(first),
1724
+ }
1725
+ rendered = TEMPLATES[template_id].render(variables)
1726
+ _print(_color("--- First mail preview ---", "36", enabled=colors))
1727
+ _print(f"From: {sender}")
1728
+ _print(f"To: {first.primary_email}")
1729
+ _print(f"Subject: {rendered['subject']}")
1730
+ _print("")
1731
+ _print(rendered["text"])
1732
+ _print(_color("--- end preview ---", "36", enabled=colors))
1733
+ _print("")
1734
+
1735
+ if not confirm:
1736
+ _print(
1737
+ _color(
1738
+ "Dry-run mode. Pass --confirm to send. Nothing was mailed.",
1739
+ "33",
1740
+ enabled=colors,
1741
+ )
1742
+ )
1743
+ return 0
1744
+
1745
+ # Interactive y/N. Default to N on anything other than "y" / "yes".
1746
+ try:
1747
+ answer = input(f"Send to {n} contacts? [y/N]: ").strip().lower()
1748
+ except EOFError:
1749
+ answer = ""
1750
+ if answer not in ("y", "yes"):
1751
+ _print("Aborted. Nothing was mailed.")
1752
+ return 0
1753
+
1754
+ out_dir = Path.cwd() / "outreach-runs"
1755
+ outcomes = []
1756
+ aborted = False
1757
+ for i, contact in enumerate(capped, start=1):
1758
+ variables = {
1759
+ "first_name": first_name(contact),
1760
+ "personal_hook": render_personal_hook(contact),
1761
+ }
1762
+ rendered = TEMPLATES[template_id].render(variables)
1763
+ try:
1764
+ outcome = send_one(
1765
+ bunmail_base_url=bunmail_base_url,
1766
+ bunmail_api_key=api_key,
1767
+ sender=sender,
1768
+ contact=contact,
1769
+ subject=rendered["subject"],
1770
+ text=rendered["text"],
1771
+ html=rendered.get("html"),
1772
+ confirmed=True,
1773
+ )
1774
+ except OutreachError as exc:
1775
+ _print(
1776
+ _color(
1777
+ f"[{i}/{n}] STOPPING on 4xx from bunmail: {exc}",
1778
+ "31",
1779
+ enabled=colors,
1780
+ )
1781
+ )
1782
+ aborted = True
1783
+ break
1784
+ outcomes.append(outcome)
1785
+ _print(f"[{i}/{n}] {outcome.status} {contact.primary_email} (id={outcome.email_id or '-'})")
1786
+ if i < n:
1787
+ time.sleep(DEFAULT_SEND_PAUSE_SECS)
1788
+
1789
+ log_path = write_run_log(out_dir, outcomes)
1790
+ _print("")
1791
+ _print(_color(f"Wrote run log: {log_path}", "32", enabled=colors))
1792
+ return 1 if aborted else 0
1793
+
1794
+
1795
+ def _run_invite_interviews(
1796
+ *,
1797
+ csv_path: str,
1798
+ max_invites: int,
1799
+ only_since_days: int | None,
1800
+ confirm: bool,
1801
+ ) -> int:
1802
+ """Waitlist-driven interview invites. Sibling of `outreach send`.
1803
+
1804
+ Layout (mirrors the rolodex flow on purpose, so the operator's
1805
+ muscle memory carries):
1806
+ 1. Validate env (ZENO_BUNMAIL_API_KEY required to even dry-run).
1807
+ 2. Load CSV, apply suppression + optional recency filter, cap.
1808
+ 3. Print dry-run plan (first 5 + full first-mail render).
1809
+ 4. If --confirm: interactive y/N -> iterate, sleep, log.
1810
+ 5. On --confirm, write outreach-runs/YYYY-MM-DD-HHMM.csv as an audit
1811
+ trail (dry-run / abort / nothing-to-send paths write nothing).
1812
+ """
1813
+ from zeno_api.email_templates import TEMPLATES # noqa: PLC0415
1814
+
1815
+ from .interview_invites import ( # noqa: PLC0415
1816
+ HARD_MAX_INVITES,
1817
+ InviteSendConfig,
1818
+ filter_signups,
1819
+ first_name_or_default,
1820
+ load_signups_csv,
1821
+ render_signup_context,
1822
+ send_invites,
1823
+ )
1824
+ from .outreach import ( # noqa: PLC0415
1825
+ OutreachError,
1826
+ load_suppression_list,
1827
+ write_run_log,
1828
+ )
1829
+
1830
+ colors = _colors_enabled()
1831
+ template_id = "customer_interview_invite"
1832
+
1833
+ api_key = os.environ.get("ZENO_BUNMAIL_API_KEY", "").strip()
1834
+ if not api_key:
1835
+ _print(
1836
+ _color(
1837
+ "ZENO_BUNMAIL_API_KEY is not set. Refusing to run.",
1838
+ "31",
1839
+ enabled=colors,
1840
+ )
1841
+ )
1842
+ return 2
1843
+
1844
+ if max_invites <= 0:
1845
+ _print("--max must be positive.")
1846
+ return 2
1847
+ if max_invites > HARD_MAX_INVITES:
1848
+ _print(f"--max {max_invites} exceeds hard cap of {HARD_MAX_INVITES}. Refusing to run.")
1849
+ return 2
1850
+
1851
+ bunmail_base_url = os.environ.get("ZENO_BUNMAIL_BASE_URL", "").strip()
1852
+ if not bunmail_base_url:
1853
+ _print(
1854
+ _color(
1855
+ "ZENO_BUNMAIL_BASE_URL is not set. Refusing to run.",
1856
+ "31",
1857
+ enabled=colors,
1858
+ )
1859
+ )
1860
+ return 2
1861
+
1862
+ sender = os.environ.get(
1863
+ "ZENO_OUTREACH_SENDER",
1864
+ "Mar at Zeno <hello@zeno.center>",
1865
+ )
1866
+
1867
+ csv_path_obj = Path(csv_path).expanduser()
1868
+ if not csv_path_obj.exists():
1869
+ _print(_color(f"CSV not found: {csv_path_obj}", "31", enabled=colors))
1870
+ return 2
1871
+
1872
+ suppression_path = Path(__file__).resolve().parents[2] / "data" / "outreach_suppression.txt"
1873
+ suppression = load_suppression_list(suppression_path)
1874
+
1875
+ all_signups = load_signups_csv(csv_path_obj)
1876
+ filtered = filter_signups(
1877
+ all_signups,
1878
+ suppression=suppression,
1879
+ only_since_days=only_since_days,
1880
+ )
1881
+ capped = filtered[:max_invites]
1882
+ n = len(capped)
1883
+
1884
+ _print(
1885
+ _color(
1886
+ f"Loaded {len(all_signups)} signups, {len(filtered)} pass filters, {n} after --max.",
1887
+ "36",
1888
+ enabled=colors,
1889
+ )
1890
+ )
1891
+ _print("")
1892
+ _print(f"Would invite {n} signups:")
1893
+ for s in capped[:5]:
1894
+ date_label = s.signed_up_at.isoformat() if s.signed_up_at else "no-date"
1895
+ _print(f" - {s.email} [{s.intent or 'no-intent'}, signed: {date_label}]")
1896
+ if n > 5:
1897
+ _print(f" ... and {n - 5} more.")
1898
+ _print("")
1899
+
1900
+ if n == 0:
1901
+ _print("Nothing to send. Exiting.")
1902
+ return 0
1903
+
1904
+ first = capped[0]
1905
+ variables = {
1906
+ "first_name": first_name_or_default(first),
1907
+ "signup_context": render_signup_context(first),
1908
+ }
1909
+ rendered = TEMPLATES[template_id].render(variables)
1910
+ _print(_color("--- First mail preview ---", "36", enabled=colors))
1911
+ _print(f"From: {sender}")
1912
+ _print(f"To: {first.email}")
1913
+ _print(f"Subject: {rendered['subject']}")
1914
+ _print("")
1915
+ _print(rendered["text"])
1916
+ _print(_color("--- end preview ---", "36", enabled=colors))
1917
+ _print("")
1918
+
1919
+ if not confirm:
1920
+ _print(
1921
+ _color(
1922
+ "Dry-run mode. Pass --confirm to send. Nothing was mailed.",
1923
+ "33",
1924
+ enabled=colors,
1925
+ )
1926
+ )
1927
+ return 0
1928
+
1929
+ try:
1930
+ answer = input(f"Send to {n} signups? [y/N]: ").strip().lower()
1931
+ except EOFError:
1932
+ answer = ""
1933
+ if answer not in ("y", "yes"):
1934
+ _print("Aborted. Nothing was mailed.")
1935
+ return 0
1936
+
1937
+ def _render_for(signup) -> dict[str, str]:
1938
+ return TEMPLATES[template_id].render(
1939
+ {
1940
+ "first_name": first_name_or_default(signup),
1941
+ "signup_context": render_signup_context(signup),
1942
+ }
1943
+ )
1944
+
1945
+ config = InviteSendConfig(
1946
+ bunmail_base_url=bunmail_base_url,
1947
+ bunmail_api_key=api_key,
1948
+ sender=sender,
1949
+ )
1950
+ try:
1951
+ outcomes, aborted = send_invites(capped, render=_render_for, config=config)
1952
+ except OutreachError as exc:
1953
+ _print(_color(f"STOPPING on 4xx from bunmail: {exc}", "31", enabled=colors))
1954
+ outcomes, aborted = [], True
1955
+
1956
+ for i, outcome in enumerate(outcomes, start=1):
1957
+ _print(f"[{i}/{n}] {outcome.status} {outcome.email} (id={outcome.email_id or '-'})")
1958
+
1959
+ out_dir = Path.cwd() / "outreach-runs"
1960
+ log_path = write_run_log(out_dir, outcomes)
1961
+ _print("")
1962
+ _print(_color(f"Wrote run log: {log_path}", "32", enabled=colors))
1963
+ return 1 if aborted else 0
1964
+
1965
+
1966
+ def _run_hook_install(settings_path: Path, *, force: bool, with_hud: bool = True) -> int:
1967
+ from .hook_install import HookInstallError, install # noqa: PLC0415
1968
+
1969
+ statusline = "zeno-hud" if with_hud else None
1970
+ try:
1971
+ result = install(settings_path, statusline_command=statusline, force=force)
1972
+ except HookInstallError as exc:
1973
+ _print(f"Error: {exc}")
1974
+ return 1
1975
+ _print(f"Installed the zeno capture hook into {settings_path}")
1976
+ _print(f" events: {', '.join(result['events'])}")
1977
+ if with_hud:
1978
+ if result["statusline"] == "set":
1979
+ _print(" statusLine: zeno-hud")
1980
+ elif result["statusline"] == "skipped-existing":
1981
+ _print(
1982
+ " statusLine: kept your existing one (pass --force to replace it with zeno-hud)"
1983
+ )
1984
+ if result["backup"]:
1985
+ _print(f" backup: {result['backup']}")
1986
+ _print(" Start a new Claude Code session for capture to begin.")
1987
+ _print(" Undo with: zeno hook uninstall")
1988
+ return 0
1989
+
1990
+
1991
+ def _run_hook_uninstall(settings_path: Path, *, restore: bool) -> int:
1992
+ from .hook_install import HookInstallError, uninstall # noqa: PLC0415
1993
+
1994
+ try:
1995
+ result = uninstall(settings_path, restore=restore)
1996
+ except HookInstallError as exc:
1997
+ _print(f"Error: {exc}")
1998
+ return 1
1999
+ if restore:
2000
+ _print(f"Restored {settings_path} from {result['restored_from']}.")
2001
+ return 0
2002
+ if result["removed_events"] or result["statusline_removed"]:
2003
+ _print(f"Removed zeno entries from {settings_path}")
2004
+ if result["removed_events"]:
2005
+ _print(f" events: {', '.join(result['removed_events'])}")
2006
+ if result["statusline_removed"]:
2007
+ _print(" statusLine: removed")
2008
+ if result["backup"]:
2009
+ _print(f" backup: {result['backup']}")
2010
+ else:
2011
+ _print(f"No zeno hook entries found in {settings_path}; nothing to remove.")
2012
+ return 0
2013
+
2014
+
2015
+ def _run_hook_status(settings_path: Path) -> int:
2016
+ from .hook_install import status # noqa: PLC0415
2017
+
2018
+ result = status(settings_path)
2019
+ colors = _colors_enabled()
2020
+ if result["all_events_installed"]:
2021
+ label = _color("installed", "32", enabled=colors)
2022
+ _print(f"zeno capture hook: {label} ({settings_path})")
2023
+ elif result["events_installed"]:
2024
+ label = _color("partial", "33", enabled=colors)
2025
+ _print(f"zeno capture hook: {label} ({settings_path})")
2026
+ _print(f" installed events: {', '.join(result['events_installed'])}")
2027
+ else:
2028
+ label = _color("not installed", "33", enabled=colors)
2029
+ _print(f"zeno capture hook: {label} ({settings_path})")
2030
+ _print(" Run 'zeno hook install' to start capturing Claude Code sessions.")
2031
+ return 0
2032
+
2033
+
2034
+ def _warn_if_no_capture_hook(hud_install_mod, settings_path: str | None) -> None:
2035
+ """The cognition line is read-only; without the capture hook nothing writes
2036
+ cognition_samples, so the bar shows '--'. Nudge the user to install the hook."""
2037
+ if not hud_install_mod.capture_hook_present(settings_path):
2038
+ _print(" note: the cognition line is read-only; run 'zeno hook install' so the")
2039
+ _print(" capture hook writes att/eff/drv (without it the bar shows --).")
2040
+
2041
+
2042
+ def _run_hud_install(
2043
+ *,
2044
+ target: str,
2045
+ dry_run: bool,
2046
+ force: bool,
2047
+ settings_path: str | None,
2048
+ ccstatusline_path: str | None,
2049
+ ) -> int:
2050
+ from .hud import hud_install as H # noqa: PLC0415
2051
+
2052
+ ccpath = Path(ccstatusline_path).expanduser() if ccstatusline_path else None
2053
+ res = H.install(
2054
+ target,
2055
+ settings_path=settings_path,
2056
+ ccstatusline_path=ccpath,
2057
+ dry_run=dry_run,
2058
+ force=force,
2059
+ )
2060
+ # Use the (dry-run) form to match `zeno onboard`; with _print now escaping
2061
+ # markup the bracketed form would also survive, but one convention is clearer.
2062
+ tag = "(dry-run) " if dry_run else ""
2063
+ chosen = res["target"]
2064
+
2065
+ if chosen == "none":
2066
+ _print("No supported HUD detected (ccstatusline config or claude-hud plugin).")
2067
+ _print(" Install one, then re-run 'zeno hud install' (or pass --target explicitly):")
2068
+ _print(" ccstatusline: https://github.com/sirmalloc/ccstatusline")
2069
+ _print(" claude-hud: https://github.com/jarrodwatts/claude-hud")
2070
+ return 0 # auto-detect is a friendly no-op
2071
+
2072
+ if chosen == "ccstatusline":
2073
+ if res["action"] == "not-found":
2074
+ _print(f"ccstatusline config not found at {res['config']}.")
2075
+ _print(" Configure ccstatusline first, or run 'zeno hud install --target claude-hud'.")
2076
+ return 1 # explicit, impossible target
2077
+ _print(f"{tag}ccstatusline: a dedicated custom-command line running the zeno bar:")
2078
+ _print(f" command: {res['command']}")
2079
+ _print(f" config: {res['config']} (action: {res['action']})")
2080
+ if not dry_run and res.get("backup"):
2081
+ _print(f" backup: {res['backup']}")
2082
+ if not dry_run:
2083
+ _warn_if_no_capture_hook(H, settings_path)
2084
+ _print(" Open a new Claude Code session to see it under your ccstatusline rows.")
2085
+ _print(" Undo with: zeno hud uninstall")
2086
+ return 0
2087
+
2088
+ # claude-hud
2089
+ _print(f"{tag}claude-hud: set statusLine to the zeno wrapper (claude-hud, then the bar):")
2090
+ _print(f" wrapper: {res['wrapper']}")
2091
+ _print(f" settings: {res.get('settings_resolved', res.get('settings_path'))}")
2092
+ _print(f" bar: {res['bar_command']}")
2093
+ found = res.get("claude_hud_found")
2094
+ ch_note = (
2095
+ f"found at {found}"
2096
+ if found
2097
+ else "not detected yet (wrapper shows bar-only until installed)"
2098
+ )
2099
+ _print(f" claude-hud: {ch_note}")
2100
+ if res.get("node_missing"):
2101
+ _print(" note: node is not on PATH; the wrapper shows bar-only until node is available.")
2102
+ if not dry_run:
2103
+ if res.get("statusline") == "skipped-existing":
2104
+ _print(" Kept your existing statusLine (pass --force to replace it with the wrapper).")
2105
+ return 0
2106
+ if res.get("backup"):
2107
+ _print(f" backup: {res['backup']}")
2108
+ _warn_if_no_capture_hook(H, settings_path)
2109
+ _print(" Open a new Claude Code session to see claude-hud + the zeno line stacked.")
2110
+ _print(" Undo with: zeno hud uninstall")
2111
+ return 0
2112
+
2113
+
2114
+ def _run_hud_uninstall(
2115
+ *, settings_path: str | None, ccstatusline_path: str | None, restore: bool
2116
+ ) -> int:
2117
+ from .hud import hud_install as H # noqa: PLC0415
2118
+
2119
+ ccpath = Path(ccstatusline_path).expanduser() if ccstatusline_path else None
2120
+ try:
2121
+ res = H.uninstall(settings_path=settings_path, ccstatusline_path=ccpath, restore=restore)
2122
+ except H.HudInstallError as exc:
2123
+ _print(f"Error: {exc}")
2124
+ return 1
2125
+ cc, ch = res["ccstatusline"], res["claude_hud"]
2126
+ if restore:
2127
+ if cc.get("restored_from"):
2128
+ _print(f"Restored ccstatusline config from {cc['restored_from']}.")
2129
+ if ch.get("restored_from"):
2130
+ _print(f"Restored {res['settings_path']} from {ch['restored_from']}.")
2131
+ if not cc.get("restored_from") and not ch.get("restored_from"):
2132
+ _print("No zeno backups found to restore.")
2133
+ return 0
2134
+ did = False
2135
+ if cc.get("removed"):
2136
+ _print(f"Removed the zeno cognition line from ccstatusline ({cc['config']}).")
2137
+ did = True
2138
+ if ch.get("statusline_removed"):
2139
+ _print(f"Removed the zeno statusLine from {res['settings_path']}.")
2140
+ did = True
2141
+ if ch.get("wrapper_removed"):
2142
+ _print("Removed the zeno-hud-wrapper script.")
2143
+ did = True
2144
+ if not did:
2145
+ _print("No zeno HUD entries found; nothing to remove.")
2146
+ return 0
2147
+
2148
+
2149
+ def _run_hud_status(*, settings_path: str | None, ccstatusline_path: str | None) -> int:
2150
+ from .hud import hud_install as H # noqa: PLC0415
2151
+
2152
+ ccpath = Path(ccstatusline_path).expanduser() if ccstatusline_path else None
2153
+ s = H.detect_status(ccstatusline_path=ccpath, settings_path=settings_path)
2154
+ colors = _colors_enabled()
2155
+
2156
+ def yn(b: bool) -> str:
2157
+ return _color("yes", "32", enabled=colors) if b else _color("no", "33", enabled=colors)
2158
+
2159
+ _print("zeno HUD add-on status")
2160
+ _print("")
2161
+ _print(
2162
+ f" ccstatusline present: {yn(s['ccstatusline_present'])} ({s['ccstatusline_config']})"
2163
+ )
2164
+ _print(f" ccstatusline wired: {yn(s['ccstatusline_installed'])}")
2165
+ ch_path = f" ({s['claude_hud_path']})" if s["claude_hud_path"] else ""
2166
+ _print(f" claude-hud present: {yn(s['claude_hud_present'])}{ch_path}")
2167
+ _print(f" claude-hud wired: {yn(s['claude_hud_installed'])}")
2168
+ _print(f" (statusLine target: {s['settings_path']})")
2169
+ _print(f" wrapper script present: {yn(s['wrapper_present'])}")
2170
+ _print(f" capture hook installed: {yn(s['capture_hook_present'])} (writes att/eff/drv)")
2171
+ _print(f" recommended adapter: {s['recommended']}")
2172
+ if not s["capture_hook_present"]:
2173
+ _print(
2174
+ " note: without the capture hook the read-only bar shows --; run 'zeno hook install'."
2175
+ )
2176
+ return 0
2177
+
2178
+
2179
+ def _print_subcommand_index() -> None:
2180
+ """Grouped subcommand index, printed after the zero-args status snapshot.
2181
+
2182
+ Grouped by purpose so the operator scans 6 sections (4-5 commands each)
2183
+ instead of one flat 14-item list. Matches the gh / doctl pattern of
2184
+ "core" group first, "extras" later. Reuses the same labels as the
2185
+ argparse help; keep these in sync by hand (cheap, low-churn).
2186
+ """
2187
+ colors = _colors_enabled()
2188
+ header = _color("Subcommands:", "36", enabled=colors)
2189
+ _print(header)
2190
+ groups: list[tuple[str, list[tuple[str, str]]]] = [
2191
+ (
2192
+ "Measure",
2193
+ [
2194
+ ("survey", "Run the RTLX-S 5-item probe."),
2195
+ ("report", "Print the last-session summary."),
2196
+ ("curve", "Render the Babysitting Tax curve + save PNG."),
2197
+ ("weekly", "Print the weekly digest table."),
2198
+ ],
2199
+ ),
2200
+ (
2201
+ "Inspect",
2202
+ [
2203
+ ("status", "One-screen tier + streak + cognitive snapshot."),
2204
+ ("tips", "Print today's rotating tip."),
2205
+ ("doctor", "Run diagnostic checks against local + API."),
2206
+ ("version", "Print the version banner."),
2207
+ ],
2208
+ ),
2209
+ (
2210
+ "Account",
2211
+ [
2212
+ ("login", "Authenticate the CLI via the browser."),
2213
+ ("logout", "Clear the stored Clerk token."),
2214
+ ("whoami", "Show the identity + tier the API sees."),
2215
+ ],
2216
+ ),
2217
+ (
2218
+ "Setup",
2219
+ [
2220
+ ("onboard", "One command: capture hook + HUD + doctor (start here)."),
2221
+ ("hook install", "Install the Claude Code capture hook."),
2222
+ ("hook status", "Show whether capture is wired up."),
2223
+ ("completion", "Print a bash/zsh/fish completion script."),
2224
+ ],
2225
+ ),
2226
+ (
2227
+ "Billing + metering",
2228
+ [
2229
+ ("billing recommend-tier", "Recommend a tier for N engineers."),
2230
+ ("billing preview-tier", "Show configured Stripe price for (tier, period)."),
2231
+ ("metrics emit", "Record a usage event to the meter."),
2232
+ ],
2233
+ ),
2234
+ (
2235
+ "Session intelligence",
2236
+ [
2237
+ ("ingest", "Ingest coding-agent transcripts into the capture DB."),
2238
+ ("usage", "Rollups + full-text search over ingested transcripts."),
2239
+ ],
2240
+ ),
2241
+ (
2242
+ "Mail + outreach",
2243
+ [
2244
+ ("email check-dns", "Verify SPF/DKIM/DMARC for sending domain."),
2245
+ ("outreach send", "Rolodex-driven outreach via bunmail (dry-run)."),
2246
+ ("outreach invite-interviews", "Mail waitlist signups (dry-run)."),
2247
+ ],
2248
+ ),
2249
+ ]
2250
+ for title, commands in groups:
2251
+ _print(f" {_color(title, '36', enabled=colors)}")
2252
+ col_width = max(len(name) for name, _ in commands)
2253
+ for name, blurb in commands:
2254
+ _print(f" {name.ljust(col_width)} {blurb}")
2255
+ _print("")
2256
+
2257
+
2258
+ # Second-level verbs per top-level command, used only by the completion
2259
+ # generator (the passthrough commands ingest/usage own their own completion,
2260
+ # so they are intentionally flat here).
2261
+ _NESTED_COMMANDS: dict[str, tuple[str, ...]] = {
2262
+ "billing": ("recommend-tier", "preview-tier"),
2263
+ "metrics": ("emit",),
2264
+ "hook": ("install", "uninstall", "status", "run"),
2265
+ "hud": ("bar", "install", "uninstall", "status"),
2266
+ "email": ("check-dns",),
2267
+ "outreach": ("send", "invite-interviews"),
2268
+ "completion": ("bash", "zsh", "fish"),
2269
+ }
2270
+
2271
+
2272
+ def _completion_script(shell: str) -> str:
2273
+ """Return a static completion script for bash/zsh/fish.
2274
+
2275
+ Dependency-free: top-level verbs and their direct sub-verbs are baked in at
2276
+ generation time. Completes the first verb and, for the few nested commands,
2277
+ the second verb. Flags are intentionally left out (they churn and argparse
2278
+ already documents them via --help)."""
2279
+ top = " ".join(TOP_LEVEL_COMMANDS)
2280
+ if shell == "bash":
2281
+ # Build a case arm per nested command so `zeno hook <tab>` completes.
2282
+ nested_arms = "\n".join(
2283
+ f' {cmd}) COMPREPLY=( $(compgen -W "{" ".join(subs)}"' ' -- "$cur") ); return ;;'
2284
+ for cmd, subs in _NESTED_COMMANDS.items()
2285
+ )
2286
+ return (
2287
+ "# zeno bash completion. Install: zeno completion bash > /etc/bash_completion.d/zeno\n"
2288
+ '# or for one shell: eval "$(zeno completion bash)"\n'
2289
+ "_zeno_complete() {\n"
2290
+ " local cur prev words cword\n"
2291
+ ' cur="${COMP_WORDS[COMP_CWORD]}"\n'
2292
+ ' if [ "$COMP_CWORD" -eq 1 ]; then\n'
2293
+ f' COMPREPLY=( $(compgen -W "{top}" -- "$cur") ); return\n'
2294
+ " fi\n"
2295
+ ' case "${COMP_WORDS[1]}" in\n'
2296
+ f"{nested_arms}\n"
2297
+ " esac\n"
2298
+ "}\n"
2299
+ "complete -F _zeno_complete zeno\n"
2300
+ )
2301
+ if shell == "zsh":
2302
+ nested_arms = "\n".join(
2303
+ f" {cmd}) compadd {' '.join(subs)} ;;"
2304
+ for cmd, subs in _NESTED_COMMANDS.items()
2305
+ )
2306
+ return (
2307
+ "#compdef zeno\n"
2308
+ '# zeno zsh completion. Install: zeno completion zsh > "${fpath[1]}/_zeno"\n'
2309
+ '# or for one shell: eval "$(zeno completion zsh)"\n'
2310
+ "_zeno() {\n"
2311
+ " if (( CURRENT == 2 )); then\n"
2312
+ f" compadd {top}\n"
2313
+ " return\n"
2314
+ " fi\n"
2315
+ " if (( CURRENT == 3 )); then\n"
2316
+ ' case "${words[2]}" in\n'
2317
+ f"{nested_arms}\n"
2318
+ " esac\n"
2319
+ " fi\n"
2320
+ "}\n"
2321
+ "compdef _zeno zeno\n"
2322
+ )
2323
+ # fish
2324
+ lines = [
2325
+ "# zeno fish completion. Install:",
2326
+ "# zeno completion fish > ~/.config/fish/completions/zeno.fish",
2327
+ "# Top-level commands (only when no subcommand is typed yet):",
2328
+ ]
2329
+ for cmd in TOP_LEVEL_COMMANDS:
2330
+ lines.append(f"complete -c zeno -n '__fish_use_subcommand' -a '{cmd}'")
2331
+ for cmd, subs in _NESTED_COMMANDS.items():
2332
+ for sub in subs:
2333
+ lines.append(f"complete -c zeno -n '__fish_seen_subcommand_from {cmd}' -a '{sub}'")
2334
+ return "\n".join(lines) + "\n"
2335
+
2336
+
2337
+ def main() -> None:
2338
+ # Pass-through subcommands forward EVERYTHING after the verb (including
2339
+ # --help and leading options) to the folded session-intel sub-CLI. Done
2340
+ # before argparse on purpose: argparse.REMAINDER mishandles a remainder that
2341
+ # starts with an option (e.g. `zeno usage --search x` would route --search to
2342
+ # the top-level parser and error). The subparsers are still registered in
2343
+ # _build_parser so they appear in `zeno --help` and the subcommand index.
2344
+ argv = sys.argv[1:]
2345
+ if argv and argv[0] == "ingest":
2346
+ raise SystemExit(_run_session_intel_passthrough("zeno_session_intel.ingest", argv[1:]))
2347
+ if argv and argv[0] == "usage":
2348
+ raise SystemExit(_run_session_intel_passthrough("zeno_session_intel.analytics", argv[1:]))
2349
+ # `zeno hook run` is fired by Claude Code on every captured event, so handle
2350
+ # it before building the full parser (fast path + clean stdin passthrough). It
2351
+ # execs the bundled cc-bridge hook and never blocks CC.
2352
+ if argv[:2] == ["hook", "run"]:
2353
+ from .hook_install import run as _hook_run # noqa: PLC0415
2354
+
2355
+ raise SystemExit(_hook_run())
2356
+ # `zeno hud bar` is fired by Claude Code on every statusLine render (the
2357
+ # `zeno hud bar` install form). Handle it before argparse for a fast path +
2358
+ # clean stdin passthrough, exactly like `hook run`. bar_main is crash-safe
2359
+ # (prints an empty line + exit 0 on any error) so it never breaks the host HUD.
2360
+ if argv[:2] == ["hud", "bar"]:
2361
+ from .hud.zeno_hud import bar_main as _bar_main # noqa: PLC0415
2362
+
2363
+ _bar_main()
2364
+ raise SystemExit(0)
2365
+
2366
+ parser = _build_parser()
2367
+ args = parser.parse_args()
2368
+ if args.command is None:
2369
+ # Zero-args default: print a one-line greeter, then run status,
2370
+ # then a grouped subcommand index. Matches gh / doctl / fly: the
2371
+ # bare CLI invocation should answer "where am I" and "what can I do"
2372
+ # in one screen. See research prompt #24.
2373
+ colors = _colors_enabled()
2374
+ header = _color("zeno", "36", enabled=colors)
2375
+ _print(f"{header} - measure the supervision cost of AI-assisted work.")
2376
+ _print("Run 'zeno --help' for the full command surface.")
2377
+ _print("")
2378
+ exit_code = _run_status(project="default-project", base_url=DEFAULT_API_BASE_URL)
2379
+ _print("")
2380
+ _print_subcommand_index()
2381
+ raise SystemExit(exit_code)
2382
+ if args.command == "survey":
2383
+ raise SystemExit(
2384
+ _run_survey(
2385
+ project=args.project,
2386
+ harness=args.harness,
2387
+ no_probes=args.no_probes,
2388
+ autonomy=args.autonomy,
2389
+ sced_blinded=args.sced,
2390
+ sced_session_index=args.sced_index,
2391
+ )
2392
+ )
2393
+ if args.command == "report":
2394
+ raise SystemExit(_run_report(project=args.project))
2395
+ if args.command == "curve":
2396
+ raise SystemExit(_run_curve(project=args.project, png=args.png))
2397
+ if args.command == "weekly":
2398
+ raise SystemExit(_run_weekly(project=args.project))
2399
+ if args.command == "status":
2400
+ raise SystemExit(_run_status(project=args.project, base_url=args.base_url))
2401
+ if args.command == "tips":
2402
+ raise SystemExit(_run_tips())
2403
+ if args.command == "onboard":
2404
+ raise SystemExit(
2405
+ _run_onboard(
2406
+ base_url=args.base_url,
2407
+ dry_run=args.dry_run,
2408
+ force=args.force,
2409
+ settings_path=args.settings_path,
2410
+ ccstatusline_path=args.ccstatusline_path,
2411
+ )
2412
+ )
2413
+ if args.command == "doctor":
2414
+ raise SystemExit(_run_doctor(base_url=args.base_url))
2415
+ if args.command == "version":
2416
+ raise SystemExit(_run_version())
2417
+ if args.command == "completion":
2418
+ # Write the script raw (not via _print): completion scripts must not be
2419
+ # markup-escaped or rich-styled - they are sourced by the shell verbatim.
2420
+ sys.stdout.write(_completion_script(args.shell))
2421
+ raise SystemExit(0)
2422
+ if args.command == "login":
2423
+ raise SystemExit(
2424
+ _run_login(authorize_url=args.authorize_url, open_browser=not args.no_browser)
2425
+ )
2426
+ if args.command == "logout":
2427
+ raise SystemExit(_run_logout())
2428
+ if args.command == "whoami":
2429
+ raise SystemExit(_run_whoami(base_url=args.base_url))
2430
+ if args.command == "billing":
2431
+ if args.billing_command == "recommend-tier":
2432
+ raise SystemExit(
2433
+ _run_billing_recommend_tier(engineers=args.engineers, base_url=args.base_url)
2434
+ )
2435
+ if args.billing_command == "preview-tier":
2436
+ raise SystemExit(
2437
+ _run_billing_preview_tier(
2438
+ tier=args.tier, period=args.period, base_url=args.base_url
2439
+ )
2440
+ )
2441
+ if args.command == "metrics":
2442
+ if args.metrics_command == "emit":
2443
+ raise SystemExit(
2444
+ _run_metrics_emit(
2445
+ event=args.event,
2446
+ quantity=args.quantity,
2447
+ metadata=args.metadata,
2448
+ base_url=args.base_url,
2449
+ )
2450
+ )
2451
+ if args.command == "hook":
2452
+ from . import hook_install # noqa: PLC0415
2453
+
2454
+ settings_path = (
2455
+ Path(args.settings_path).expanduser()
2456
+ if args.settings_path
2457
+ else hook_install.default_settings_path()
2458
+ )
2459
+ if args.hook_command == "install":
2460
+ raise SystemExit(
2461
+ _run_hook_install(settings_path, force=args.force, with_hud=not args.no_hud)
2462
+ )
2463
+ if args.hook_command == "uninstall":
2464
+ raise SystemExit(_run_hook_uninstall(settings_path, restore=args.restore))
2465
+ if args.hook_command == "status":
2466
+ raise SystemExit(_run_hook_status(settings_path))
2467
+ if args.hook_command == "run":
2468
+ raise SystemExit(hook_install.run())
2469
+ if args.command == "hud":
2470
+ if args.hud_command == "install":
2471
+ raise SystemExit(
2472
+ _run_hud_install(
2473
+ target=args.target,
2474
+ dry_run=args.dry_run,
2475
+ force=args.force,
2476
+ settings_path=args.settings_path,
2477
+ ccstatusline_path=args.ccstatusline_path,
2478
+ )
2479
+ )
2480
+ if args.hud_command == "uninstall":
2481
+ raise SystemExit(
2482
+ _run_hud_uninstall(
2483
+ settings_path=args.settings_path,
2484
+ ccstatusline_path=args.ccstatusline_path,
2485
+ restore=args.restore,
2486
+ )
2487
+ )
2488
+ if args.hud_command == "status":
2489
+ raise SystemExit(
2490
+ _run_hud_status(
2491
+ settings_path=args.settings_path,
2492
+ ccstatusline_path=args.ccstatusline_path,
2493
+ )
2494
+ )
2495
+ if args.hud_command == "bar":
2496
+ # parity path (the fast-path in main() normally handles `hud bar` first)
2497
+ from .hud.zeno_hud import bar_main as _bar_main # noqa: PLC0415
2498
+
2499
+ _bar_main()
2500
+ raise SystemExit(0)
2501
+ if args.command == "email":
2502
+ if args.email_command == "check-dns":
2503
+ raise SystemExit(
2504
+ _run_email_check_dns(
2505
+ domain=args.domain,
2506
+ dkim_selector=args.dkim_selector,
2507
+ mail_host=args.mail_host,
2508
+ )
2509
+ )
2510
+ if args.command == "outreach":
2511
+ if args.outreach_command == "send":
2512
+ raise SystemExit(
2513
+ _run_outreach_send(
2514
+ csv_path=args.csv,
2515
+ template_id=args.template,
2516
+ filter_tag=args.filter_tag,
2517
+ max_sends=args.max_sends,
2518
+ exclude_recent_days=args.exclude_recent_days,
2519
+ confirm=args.confirm,
2520
+ )
2521
+ )
2522
+ if args.outreach_command == "invite-interviews":
2523
+ raise SystemExit(
2524
+ _run_invite_interviews(
2525
+ csv_path=args.csv,
2526
+ max_invites=args.max_invites,
2527
+ only_since_days=args.only_since_days,
2528
+ confirm=args.confirm,
2529
+ )
2530
+ )
2531
+
2532
+
2533
+ if __name__ == "__main__":
2534
+ main()