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.
- zeno_adapters/__init__.py +17 -0
- zeno_adapters/_common.py +38 -0
- zeno_adapters/anthropic.py +68 -0
- zeno_adapters/claude_code.py +101 -0
- zeno_adapters/crewai.py +92 -0
- zeno_adapters/langgraph.py +49 -0
- zeno_adapters/openai.py +108 -0
- zeno_cli/__init__.py +1 -0
- zeno_cli/_hooks/cc_bridge.py +1016 -0
- zeno_cli/doctor.py +535 -0
- zeno_cli/hook_install.py +269 -0
- zeno_cli/hud/__init__.py +1 -0
- zeno_cli/hud/hud_install.py +652 -0
- zeno_cli/hud/zeno_attention.py +288 -0
- zeno_cli/hud/zeno_cognition.py +457 -0
- zeno_cli/hud/zeno_hud.py +496 -0
- zeno_cli/interview_invites.py +342 -0
- zeno_cli/login.py +241 -0
- zeno_cli/main.py +2534 -0
- zeno_cli/onboard.py +206 -0
- zeno_cli/outreach.py +456 -0
- zeno_cli/version.py +67 -0
- zeno_cli-0.3.4.dist-info/METADATA +161 -0
- zeno_cli-0.3.4.dist-info/RECORD +69 -0
- zeno_cli-0.3.4.dist-info/WHEEL +4 -0
- zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
- zeno_core/__init__.py +67 -0
- zeno_core/analytics.py +193 -0
- zeno_core/rtlx_s.py +460 -0
- zeno_core/streak.py +178 -0
- zeno_core/tlx_s.py +192 -0
- zeno_sdk/__init__.py +6 -0
- zeno_sdk/_generated/__init__.py +6 -0
- zeno_sdk/_generated/client.py +819 -0
- zeno_sdk/_migrations/alembic/env.py +33 -0
- zeno_sdk/_migrations/alembic/script.py.mako +18 -0
- zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
- zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
- zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
- zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
- zeno_sdk/_migrations/alembic.ini +35 -0
- zeno_sdk/_runtime.py +12 -0
- zeno_sdk/adapters/__init__.py +15 -0
- zeno_sdk/adapters/anthropic.py +5 -0
- zeno_sdk/adapters/claude_code.py +5 -0
- zeno_sdk/adapters/crewai.py +5 -0
- zeno_sdk/adapters/langgraph.py +5 -0
- zeno_sdk/adapters/openai.py +5 -0
- zeno_sdk/auth.py +25 -0
- zeno_sdk/client.py +87 -0
- zeno_sdk/config.py +61 -0
- zeno_sdk/daemon.py +72 -0
- zeno_sdk/privacy.py +46 -0
- zeno_sdk/session.py +179 -0
- zeno_sdk/storage.py +487 -0
- zeno_sdk/types/__init__.py +121 -0
- zeno_session_intel/__init__.py +19 -0
- zeno_session_intel/analytics.py +588 -0
- zeno_session_intel/compression.py +123 -0
- zeno_session_intel/ingest.py +376 -0
- zeno_session_intel/model.py +129 -0
- zeno_session_intel/parsers/__init__.py +31 -0
- zeno_session_intel/parsers/claude_code.py +169 -0
- zeno_session_intel/parsers/codex.py +265 -0
- zeno_session_intel/parsers/cursor.py +198 -0
- zeno_session_intel/prices.py +281 -0
- zeno_session_intel/schema.py +277 -0
- zeno_session_intel/signals.py +319 -0
- 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()
|