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
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Waitlist-driven interview-invite outreach.
|
|
2
|
+
|
|
3
|
+
Placeholder waitlist source: a CSV with the same shape the
|
|
4
|
+
/api/waitlist Vercel function sends to the Zeno API (email, role,
|
|
5
|
+
intent, why, plus an optional first_name and signed_up_at column the
|
|
6
|
+
operator may have hand-edited). The CSV path is passed on the CLI -
|
|
7
|
+
when a real waitlist DB lands, this module gains a `load_signups_db`
|
|
8
|
+
function and the CLI gets a `--source db` switch; the renderer + send
|
|
9
|
+
loop stay unchanged.
|
|
10
|
+
|
|
11
|
+
Architectural notes:
|
|
12
|
+
|
|
13
|
+
* Sibling of `outreach.py` (rolodex sends) not a merge: rolodex
|
|
14
|
+
contacts are *outbound prospects* with a relationship score and
|
|
15
|
+
tags, waitlist signups are *inbound leads* with an intent and an
|
|
16
|
+
optional why. The selection criteria differ; keeping the modules
|
|
17
|
+
separate means changes to one do not regress the other.
|
|
18
|
+
|
|
19
|
+
* The bunmail wire format and the safety contract (confirmed=True
|
|
20
|
+
required, dry-run prints first-mail preview) match outreach.py so
|
|
21
|
+
the operator's muscle memory carries between commands.
|
|
22
|
+
|
|
23
|
+
* `render_signup_context` is the personal-hook analogue here: it
|
|
24
|
+
turns the `intent` + `why` fields into one sentence that justifies
|
|
25
|
+
the cold-but-not-really invite mail. When neither field is present
|
|
26
|
+
we fall back to a generic but honest line.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import csv
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from datetime import date, datetime, timedelta
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
from .outreach import (
|
|
39
|
+
DEFAULT_SEND_PAUSE_SECS,
|
|
40
|
+
OutreachError,
|
|
41
|
+
SendOutcome,
|
|
42
|
+
send_one,
|
|
43
|
+
)
|
|
44
|
+
from .outreach import (
|
|
45
|
+
Contact as RolodexContact,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Hard ceiling on a single run. The CLI default --max is 10 (per the
|
|
49
|
+
# product spec) and this is the absolute backstop against an
|
|
50
|
+
# accidentally-large blast.
|
|
51
|
+
HARD_MAX_INVITES = 50
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class WaitlistSignup:
|
|
56
|
+
"""One row of the waitlist export CSV, narrowed to fields the
|
|
57
|
+
invite renderer uses.
|
|
58
|
+
|
|
59
|
+
`first_name` is optional - the rolodex carries it but the waitlist
|
|
60
|
+
form on /index.html does not collect it yet, so the renderer falls
|
|
61
|
+
back to "there" when blank. `signed_up_at` is parsed as a date
|
|
62
|
+
when present; missing means "no recency filter applied".
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
email: str
|
|
66
|
+
first_name: str
|
|
67
|
+
role: str
|
|
68
|
+
intent: str # pro_trial / research_updates / both
|
|
69
|
+
why: str
|
|
70
|
+
signed_up_at: date | None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# CSV ingestion
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_signups_csv(path: str | Path) -> list[WaitlistSignup]:
|
|
79
|
+
"""Load + normalize a waitlist export CSV.
|
|
80
|
+
|
|
81
|
+
Expected columns (extra columns ignored, missing columns treated
|
|
82
|
+
as blank):
|
|
83
|
+
- email REQUIRED. Row dropped if missing or empty.
|
|
84
|
+
- first_name optional. Falls back to "" -> "there" at render.
|
|
85
|
+
- role optional. Pass-through for the operator's notes.
|
|
86
|
+
- intent optional. One of pro_trial / research_updates /
|
|
87
|
+
both; renderer accepts any value but recognised
|
|
88
|
+
intents trigger a more specific signup_context.
|
|
89
|
+
- why optional. Free-text from the signup form.
|
|
90
|
+
- signed_up_at optional. YYYY-MM-DD. Enables --only-since N.
|
|
91
|
+
"""
|
|
92
|
+
path = Path(path)
|
|
93
|
+
out: list[WaitlistSignup] = []
|
|
94
|
+
with path.open("r", encoding="utf-8", newline="") as fh:
|
|
95
|
+
reader = csv.DictReader(fh)
|
|
96
|
+
for row in reader:
|
|
97
|
+
email = (row.get("email") or "").strip()
|
|
98
|
+
if not email:
|
|
99
|
+
continue
|
|
100
|
+
out.append(
|
|
101
|
+
WaitlistSignup(
|
|
102
|
+
email=email,
|
|
103
|
+
first_name=(row.get("first_name") or "").strip(),
|
|
104
|
+
role=(row.get("role") or "").strip(),
|
|
105
|
+
intent=(row.get("intent") or "").strip().lower(),
|
|
106
|
+
why=(row.get("why") or "").strip(),
|
|
107
|
+
signed_up_at=_parse_date(row.get("signed_up_at")),
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_date(raw: str | None) -> date | None:
|
|
114
|
+
if not raw:
|
|
115
|
+
return None
|
|
116
|
+
raw = raw.strip()
|
|
117
|
+
if not raw:
|
|
118
|
+
return None
|
|
119
|
+
try:
|
|
120
|
+
return datetime.strptime(raw, "%Y-%m-%d").date()
|
|
121
|
+
except ValueError:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Filtering
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def filter_signups(
|
|
131
|
+
signups: list[WaitlistSignup],
|
|
132
|
+
*,
|
|
133
|
+
suppression: set[str] | None = None,
|
|
134
|
+
only_since_days: int | None = None,
|
|
135
|
+
today: date | None = None,
|
|
136
|
+
) -> list[WaitlistSignup]:
|
|
137
|
+
"""Drop suppressed emails + (optionally) signups older than N days.
|
|
138
|
+
|
|
139
|
+
Suppression list is shared with rolodex outreach: a unified
|
|
140
|
+
opt-out registry. The `only_since_days` filter is opt-in because
|
|
141
|
+
older signups are valid interview candidates - the founder may
|
|
142
|
+
have a backlog they want to clear.
|
|
143
|
+
|
|
144
|
+
Sort order: most recent signup first when dates are present,
|
|
145
|
+
email A-Z as the tiebreaker. Recency-first means the operator's
|
|
146
|
+
--max takes the freshest signups, who are most likely to remember
|
|
147
|
+
why they signed up.
|
|
148
|
+
"""
|
|
149
|
+
today = today or date.today()
|
|
150
|
+
suppression = suppression or set()
|
|
151
|
+
|
|
152
|
+
out: list[WaitlistSignup] = []
|
|
153
|
+
for s in signups:
|
|
154
|
+
if s.email.lower() in suppression:
|
|
155
|
+
continue
|
|
156
|
+
if only_since_days is not None and s.signed_up_at is not None:
|
|
157
|
+
cutoff = today - timedelta(days=only_since_days)
|
|
158
|
+
if s.signed_up_at < cutoff:
|
|
159
|
+
continue
|
|
160
|
+
out.append(s)
|
|
161
|
+
|
|
162
|
+
def sort_key(s: WaitlistSignup) -> tuple[int, str]:
|
|
163
|
+
# Date.max so "no date" sorts AFTER recent dates (less priority).
|
|
164
|
+
d = s.signed_up_at or date.min
|
|
165
|
+
# Negate via toordinal() so newer dates rank lower (sort ASC).
|
|
166
|
+
return (-d.toordinal(), s.email.lower())
|
|
167
|
+
|
|
168
|
+
out.sort(key=sort_key)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Personal-context rendering
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def first_name_or_default(signup: WaitlistSignup) -> str:
|
|
178
|
+
"""Return the signup's first name, or "there" as a friendly fallback."""
|
|
179
|
+
name = signup.first_name.strip()
|
|
180
|
+
if not name:
|
|
181
|
+
return "there"
|
|
182
|
+
return name.split()[0]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def render_signup_context(signup: WaitlistSignup) -> str:
|
|
186
|
+
"""One-sentence "why I am mailing you" line for the invite body.
|
|
187
|
+
|
|
188
|
+
Priority order:
|
|
189
|
+
1. They wrote a `why` -> echo their own framing back so they know
|
|
190
|
+
this is not a form letter.
|
|
191
|
+
2. They asked for `pro_trial` -> the trial is the closest thing
|
|
192
|
+
to a yes-prospect we have at the waitlist stage.
|
|
193
|
+
3. They asked for `research_updates` -> they want depth, not a
|
|
194
|
+
trial; the call is a fair trade.
|
|
195
|
+
4. `both` -> the strongest signal of intent on the form.
|
|
196
|
+
5. Fallback: a generic but honest line - we know you signed up,
|
|
197
|
+
we want 30 minutes.
|
|
198
|
+
"""
|
|
199
|
+
why = signup.why.strip()
|
|
200
|
+
if why:
|
|
201
|
+
# Quote a truncated form of their `why` back at them. Cap to
|
|
202
|
+
# the first ~80 chars so the sentence stays mail-friendly even
|
|
203
|
+
# if they pasted a paragraph in.
|
|
204
|
+
quoted = why if len(why) <= 80 else why[:77].rstrip() + "..."
|
|
205
|
+
return (
|
|
206
|
+
f'You signed up for the Zeno waitlist and wrote: "{quoted}". '
|
|
207
|
+
"That is exactly the kind of detail I want to dig into."
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
intent = signup.intent.lower()
|
|
211
|
+
if intent == "pro_trial":
|
|
212
|
+
return (
|
|
213
|
+
"You signed up for a Pro trial, which means you have a "
|
|
214
|
+
"specific use case in mind - I want to hear what it is "
|
|
215
|
+
"before we ship the next round of invites."
|
|
216
|
+
)
|
|
217
|
+
if intent == "research_updates":
|
|
218
|
+
return (
|
|
219
|
+
"You opted in for research updates, which puts you in the "
|
|
220
|
+
"thin slice of people who care how this measurement "
|
|
221
|
+
"actually works, not just whether the demo looks good."
|
|
222
|
+
)
|
|
223
|
+
if intent == "both":
|
|
224
|
+
return (
|
|
225
|
+
"You signed up for both the Pro trial and research "
|
|
226
|
+
"updates, which is the strongest signal on the form - I "
|
|
227
|
+
"want to make sure what we ship next matches what got you "
|
|
228
|
+
"to click."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return (
|
|
232
|
+
"You signed up for the Zeno waitlist, and 30 minutes of your "
|
|
233
|
+
"perspective is more useful than another week of me guessing "
|
|
234
|
+
"what to build next."
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Adapter: WaitlistSignup -> RolodexContact (for reuse of send_one)
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _signup_to_contact(signup: WaitlistSignup) -> RolodexContact:
|
|
244
|
+
"""Wrap a waitlist signup in a Contact so we can hand it to
|
|
245
|
+
outreach.send_one() unchanged.
|
|
246
|
+
|
|
247
|
+
The Contact carries fields the bunmail send does not need
|
|
248
|
+
(relationship_score, tier, tags) so we set sensible defaults. The
|
|
249
|
+
`notes` field carries the original `why` so the run-log audit
|
|
250
|
+
trail captures intent.
|
|
251
|
+
"""
|
|
252
|
+
return RolodexContact(
|
|
253
|
+
person_id=f"waitlist:{signup.email}",
|
|
254
|
+
full_name=signup.first_name or signup.email,
|
|
255
|
+
primary_email=signup.email,
|
|
256
|
+
organization="",
|
|
257
|
+
role=signup.role,
|
|
258
|
+
tags=("waitlist",),
|
|
259
|
+
city="",
|
|
260
|
+
tier="waitlist",
|
|
261
|
+
relationship_score=0,
|
|
262
|
+
last_contact_date=None,
|
|
263
|
+
notes=signup.why,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Send loop
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass(frozen=True)
|
|
273
|
+
class InviteSendConfig:
|
|
274
|
+
"""All the knobs the CLI passes to the send loop, frozen so the
|
|
275
|
+
function signature stays stable when we add new options.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
bunmail_base_url: str
|
|
279
|
+
bunmail_api_key: str
|
|
280
|
+
sender: str
|
|
281
|
+
pause_secs: float = DEFAULT_SEND_PAUSE_SECS
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def send_invites(
|
|
285
|
+
signups: list[WaitlistSignup],
|
|
286
|
+
*,
|
|
287
|
+
render: callable,
|
|
288
|
+
config: InviteSendConfig,
|
|
289
|
+
) -> tuple[list[SendOutcome], bool]:
|
|
290
|
+
"""Iterate signups, render the invite, send through bunmail.
|
|
291
|
+
|
|
292
|
+
Returns (outcomes, aborted). `aborted` is True if bunmail returned
|
|
293
|
+
a 4xx mid-run (we stop on 4xx because it usually means the API
|
|
294
|
+
key or sender is wrong and continuing would just rack up failures).
|
|
295
|
+
|
|
296
|
+
`render(signup)` is the renderer the caller supplies - decoupled
|
|
297
|
+
so test code can swap in a stub. The default in the CLI is the
|
|
298
|
+
customer_interview_invite template registry entry.
|
|
299
|
+
"""
|
|
300
|
+
outcomes: list[SendOutcome] = []
|
|
301
|
+
aborted = False
|
|
302
|
+
n = len(signups)
|
|
303
|
+
for i, signup in enumerate(signups, start=1):
|
|
304
|
+
rendered = render(signup)
|
|
305
|
+
contact = _signup_to_contact(signup)
|
|
306
|
+
try:
|
|
307
|
+
outcome = send_one(
|
|
308
|
+
bunmail_base_url=config.bunmail_base_url,
|
|
309
|
+
bunmail_api_key=config.bunmail_api_key,
|
|
310
|
+
sender=config.sender,
|
|
311
|
+
contact=contact,
|
|
312
|
+
subject=rendered["subject"],
|
|
313
|
+
text=rendered["text"],
|
|
314
|
+
html=rendered.get("html"),
|
|
315
|
+
confirmed=True,
|
|
316
|
+
)
|
|
317
|
+
except OutreachError:
|
|
318
|
+
# send_one already encodes the bunmail body in the error;
|
|
319
|
+
# re-raise so the CLI can format it consistently with
|
|
320
|
+
# rolodex outreach error handling.
|
|
321
|
+
aborted = True
|
|
322
|
+
raise
|
|
323
|
+
except urllib.error.URLError as exc:
|
|
324
|
+
# Transport-level failures degrade to a logged error row
|
|
325
|
+
# so the run-log audit trail still records the attempt.
|
|
326
|
+
outcomes.append(
|
|
327
|
+
SendOutcome(
|
|
328
|
+
email=contact.primary_email,
|
|
329
|
+
person_id=contact.person_id,
|
|
330
|
+
full_name=contact.full_name,
|
|
331
|
+
status="error",
|
|
332
|
+
email_id="",
|
|
333
|
+
detail=f"transport: {exc.reason}",
|
|
334
|
+
sent_at=datetime.now().isoformat(timespec="seconds"),
|
|
335
|
+
)
|
|
336
|
+
)
|
|
337
|
+
continue
|
|
338
|
+
outcomes.append(outcome)
|
|
339
|
+
if i < n:
|
|
340
|
+
time.sleep(config.pause_secs)
|
|
341
|
+
|
|
342
|
+
return outcomes, aborted
|
zeno_cli/login.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""`zeno login` - browser + loopback authentication for the CLI.
|
|
2
|
+
|
|
3
|
+
Stdlib only (no extra CLI deps). The flow mirrors the OAuth "loopback
|
|
4
|
+
redirect" pattern that gh / gcloud / fly use for headless-friendly CLI auth:
|
|
5
|
+
|
|
6
|
+
1. Bind an ephemeral HTTP server on 127.0.0.1:0 (localhost only).
|
|
7
|
+
2. Open the browser at the dashboard's `/cli/authorize` bridge page with the
|
|
8
|
+
loopback port and a CSRF `state` token.
|
|
9
|
+
3. The bridge runs the Clerk sign-in, mints a `zeno-api`-audience JWT via a
|
|
10
|
+
Clerk JWT template, and redirects the browser back to
|
|
11
|
+
`http://127.0.0.1:<port>/callback?state=<state>&token=<clerk_jwt>`.
|
|
12
|
+
4. The loopback server validates `state`, captures the token, and the CLI
|
|
13
|
+
stores it in the OS keyring (`zeno_sdk.auth`).
|
|
14
|
+
|
|
15
|
+
The CLI then sends that JWT as `Authorization: Bearer ...` on API calls, so the
|
|
16
|
+
API attributes requests per Clerk user (RS256 + JWKS verification, see
|
|
17
|
+
apps/api/src/zeno_api/auth.py).
|
|
18
|
+
|
|
19
|
+
The security control on the callback today is the CSRF `state` token (browser +
|
|
20
|
+
loopback, state-CSRF) - NOT PKCE. The bridge returns the token directly in the
|
|
21
|
+
redirect; there is no authorization-code-for-token exchange, so a PKCE verifier
|
|
22
|
+
would be unused theater. TODO: when the dashboard `/cli/authorize` bridge ships,
|
|
23
|
+
implement a real OAuth code-for-token exchange (PKCE S256: send a code_challenge,
|
|
24
|
+
receive a short-lived auth code on the callback, then POST code + code_verifier
|
|
25
|
+
to the bridge to redeem the JWT) instead of receiving the token in the URL.
|
|
26
|
+
|
|
27
|
+
OUT OF SCOPE / FOLLOW-UP: the dashboard `/cli/authorize` bridge page and the
|
|
28
|
+
Clerk `zeno-api` JWT template do NOT exist yet. They are the only missing
|
|
29
|
+
pieces for an end-to-end login. This module is fully unit-testable without them
|
|
30
|
+
because `authorize_url` is injectable (env `ZENO_LOGIN_AUTHORIZE_URL`, flag
|
|
31
|
+
`--authorize-url`) and the loopback leg is driven by a fake "browser" in tests.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import json
|
|
38
|
+
import os
|
|
39
|
+
import secrets
|
|
40
|
+
import time
|
|
41
|
+
import webbrowser
|
|
42
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
43
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
44
|
+
|
|
45
|
+
from zeno_sdk import auth
|
|
46
|
+
|
|
47
|
+
# Canonical default for the dashboard CLI-authorize bridge. Injectable so the
|
|
48
|
+
# CLI ships + unit-tests before the bridge page exists. main.py mirrors this
|
|
49
|
+
# default for the argparse default (so the hot `zeno status` path does not
|
|
50
|
+
# import this module just to build the parser).
|
|
51
|
+
DEFAULT_AUTHORIZE_URL = os.environ.get(
|
|
52
|
+
"ZENO_LOGIN_AUTHORIZE_URL", "https://app.zeno.center/cli/authorize"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class LoginError(Exception):
|
|
57
|
+
"""Raised when the loopback login flow fails (timeout, CSRF, no token)."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _new_state() -> str:
|
|
61
|
+
"""Fresh CSRF state token. Split out so tests can pin it deterministically."""
|
|
62
|
+
return secrets.token_urlsafe(32)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _decode_jwt_unverified(jwt: str) -> dict:
|
|
66
|
+
"""Decode a JWT's payload WITHOUT verifying the signature - display only.
|
|
67
|
+
|
|
68
|
+
NEVER trust this for authorization; the API verifies the RS256 signature
|
|
69
|
+
against Clerk's JWKS. This is purely so `zeno login` can echo "logged in as
|
|
70
|
+
<email>" offline. Returns {} on any malformed input.
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
parts = jwt.split(".")
|
|
74
|
+
if len(parts) != 3:
|
|
75
|
+
return {}
|
|
76
|
+
payload_b64 = parts[1]
|
|
77
|
+
padding = "=" * (-len(payload_b64) % 4)
|
|
78
|
+
decoded = base64.urlsafe_b64decode(payload_b64 + padding)
|
|
79
|
+
data = json.loads(decoded)
|
|
80
|
+
except (ValueError, TypeError, json.JSONDecodeError):
|
|
81
|
+
return {}
|
|
82
|
+
return data if isinstance(data, dict) else {}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# --------------------------------------------------------------------------- #
|
|
86
|
+
# Thin wrappers over zeno_sdk.auth (keyring). Kept here so callers import one
|
|
87
|
+
# module for the whole login surface.
|
|
88
|
+
# --------------------------------------------------------------------------- #
|
|
89
|
+
def store_token(token: str) -> None:
|
|
90
|
+
"""Persist the Clerk JWT to the OS keyring."""
|
|
91
|
+
auth.set_token(token)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def load_token() -> str | None:
|
|
95
|
+
"""Read the stored Clerk JWT from the OS keyring, or None."""
|
|
96
|
+
return auth.get_token()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def clear_token() -> None:
|
|
100
|
+
"""Remove the stored Clerk JWT from the OS keyring (idempotent)."""
|
|
101
|
+
auth.clear_token()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
_SUCCESS_HTML = (
|
|
105
|
+
"<!doctype html><html><head><meta charset='utf-8'><title>zeno</title></head>"
|
|
106
|
+
"<body style='font-family:system-ui;max-width:32rem;margin:4rem auto;text-align:center'>"
|
|
107
|
+
"<h1>{heading}</h1><p>{message}</p></body></html>"
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _html_page(ok: bool, message: str) -> str:
|
|
112
|
+
heading = "You are signed in to zeno" if ok else "zeno login failed"
|
|
113
|
+
return _SUCCESS_HTML.format(heading=heading, message=message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class _LoopbackServer(HTTPServer):
|
|
117
|
+
"""Single-shot loopback server that captures the /callback result."""
|
|
118
|
+
|
|
119
|
+
expected_state: str = ""
|
|
120
|
+
login_token: str | None = None
|
|
121
|
+
login_error: str | None = None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class _CallbackHandler(BaseHTTPRequestHandler):
|
|
125
|
+
"""Handle the single GET /callback?state=&token= the bridge redirects to."""
|
|
126
|
+
|
|
127
|
+
def do_GET(self) -> None: # noqa: N802 (stdlib handler contract)
|
|
128
|
+
server: _LoopbackServer = self.server # type: ignore[assignment]
|
|
129
|
+
parsed = urlparse(self.path)
|
|
130
|
+
if parsed.path != "/callback":
|
|
131
|
+
self._respond(404, False, "Unexpected path. Close this tab and retry 'zeno login'.")
|
|
132
|
+
return
|
|
133
|
+
params = parse_qs(parsed.query)
|
|
134
|
+
error = (params.get("error") or [""])[0]
|
|
135
|
+
state = (params.get("state") or [""])[0]
|
|
136
|
+
token = (params.get("token") or [""])[0]
|
|
137
|
+
if error:
|
|
138
|
+
server.login_error = f"bridge returned error: {error}"
|
|
139
|
+
self._respond(400, False, f"Login failed: {error}")
|
|
140
|
+
return
|
|
141
|
+
if not state or not secrets.compare_digest(state, server.expected_state):
|
|
142
|
+
server.login_error = "state mismatch (possible CSRF); login aborted"
|
|
143
|
+
self._respond(400, False, "State mismatch. Close this tab and retry 'zeno login'.")
|
|
144
|
+
return
|
|
145
|
+
if not token:
|
|
146
|
+
server.login_error = "no token in callback"
|
|
147
|
+
self._respond(400, False, "No token returned. Close this tab and retry 'zeno login'.")
|
|
148
|
+
return
|
|
149
|
+
server.login_token = token
|
|
150
|
+
self._respond(
|
|
151
|
+
200, True, "Login complete. Return to your terminal - you can close this tab."
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
def _respond(self, code: int, ok: bool, message: str) -> None:
|
|
155
|
+
body = _html_page(ok, message).encode("utf-8")
|
|
156
|
+
try:
|
|
157
|
+
self.send_response(code)
|
|
158
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
159
|
+
self.send_header("Content-Length", str(len(body)))
|
|
160
|
+
self.end_headers()
|
|
161
|
+
self.wfile.write(body)
|
|
162
|
+
except OSError:
|
|
163
|
+
# Browser closed the socket early; the result is already recorded.
|
|
164
|
+
pass
|
|
165
|
+
|
|
166
|
+
def log_message(self, *args: object) -> None: # noqa: D401 (silence stderr noise)
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _announce_url(url: str, *, open_browser: bool) -> None:
|
|
171
|
+
"""Open the browser, or print the URL for SSH / headless use.
|
|
172
|
+
|
|
173
|
+
Split into its own function so tests can monkeypatch it to act as the
|
|
174
|
+
browser (hitting the loopback /callback) without a real browser, and so
|
|
175
|
+
--no-browser users get a copy-paste line.
|
|
176
|
+
"""
|
|
177
|
+
if open_browser:
|
|
178
|
+
opened = False
|
|
179
|
+
try:
|
|
180
|
+
opened = webbrowser.open(url)
|
|
181
|
+
except Exception: # webbrowser can raise a grab-bag of OS/runtime errors
|
|
182
|
+
opened = False
|
|
183
|
+
if opened:
|
|
184
|
+
print("Opened your browser to finish login. Waiting for the callback...")
|
|
185
|
+
return
|
|
186
|
+
print("Open this URL in a browser to finish login:")
|
|
187
|
+
print(f" {url}")
|
|
188
|
+
print("Waiting for the callback...")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def run_loopback_login(
|
|
192
|
+
authorize_url: str,
|
|
193
|
+
*,
|
|
194
|
+
open_browser: bool = True,
|
|
195
|
+
timeout: float = 180.0,
|
|
196
|
+
) -> str:
|
|
197
|
+
"""Run the browser + loopback leg of login and return the Clerk JWT.
|
|
198
|
+
|
|
199
|
+
Binds 127.0.0.1:0 (ephemeral port, localhost only), opens the browser to
|
|
200
|
+
`<authorize_url>?port=&state=&redirect_uri=`, then blocks until the bridge
|
|
201
|
+
redirects back to `/callback` with a matching `state` and a `token`, or
|
|
202
|
+
`timeout` elapses. The CSRF `state` token is the callback's only integrity
|
|
203
|
+
check today (no PKCE: the bridge returns the token directly, so there is no
|
|
204
|
+
code-for-token exchange a verifier could protect - see the module docstring
|
|
205
|
+
TODO for the real exchange to add when the bridge ships).
|
|
206
|
+
|
|
207
|
+
Raises LoginError on state mismatch, a missing token, a bridge error, or
|
|
208
|
+
timeout. Raises OSError if the loopback port cannot be bound.
|
|
209
|
+
"""
|
|
210
|
+
state = _new_state()
|
|
211
|
+
server = _LoopbackServer(("127.0.0.1", 0), _CallbackHandler)
|
|
212
|
+
server.expected_state = state
|
|
213
|
+
server.timeout = 1.0 # poll cadence so the deadline check stays responsive
|
|
214
|
+
port = server.server_address[1]
|
|
215
|
+
redirect_uri = f"http://127.0.0.1:{port}/callback"
|
|
216
|
+
query = urlencode(
|
|
217
|
+
{
|
|
218
|
+
"port": str(port),
|
|
219
|
+
"state": state,
|
|
220
|
+
"redirect_uri": redirect_uri,
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
url = f"{authorize_url}?{query}"
|
|
224
|
+
|
|
225
|
+
deadline = time.monotonic() + timeout
|
|
226
|
+
try:
|
|
227
|
+
_announce_url(url, open_browser=open_browser)
|
|
228
|
+
while server.login_token is None and server.login_error is None:
|
|
229
|
+
if time.monotonic() >= deadline:
|
|
230
|
+
raise LoginError(
|
|
231
|
+
f"timed out after {int(timeout)}s waiting for the browser callback"
|
|
232
|
+
)
|
|
233
|
+
server.handle_request()
|
|
234
|
+
finally:
|
|
235
|
+
server.server_close()
|
|
236
|
+
|
|
237
|
+
if server.login_error is not None:
|
|
238
|
+
raise LoginError(server.login_error)
|
|
239
|
+
if server.login_token is None: # defensive; loop only exits on token or error
|
|
240
|
+
raise LoginError("login did not complete")
|
|
241
|
+
return server.login_token
|