zeno-cli 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. zeno_adapters/__init__.py +17 -0
  2. zeno_adapters/_common.py +38 -0
  3. zeno_adapters/anthropic.py +68 -0
  4. zeno_adapters/claude_code.py +101 -0
  5. zeno_adapters/crewai.py +92 -0
  6. zeno_adapters/langgraph.py +49 -0
  7. zeno_adapters/openai.py +108 -0
  8. zeno_cli/__init__.py +1 -0
  9. zeno_cli/_hooks/cc_bridge.py +1016 -0
  10. zeno_cli/doctor.py +535 -0
  11. zeno_cli/hook_install.py +269 -0
  12. zeno_cli/hud/__init__.py +1 -0
  13. zeno_cli/hud/hud_install.py +652 -0
  14. zeno_cli/hud/zeno_attention.py +288 -0
  15. zeno_cli/hud/zeno_cognition.py +457 -0
  16. zeno_cli/hud/zeno_hud.py +496 -0
  17. zeno_cli/interview_invites.py +342 -0
  18. zeno_cli/login.py +241 -0
  19. zeno_cli/main.py +2534 -0
  20. zeno_cli/onboard.py +206 -0
  21. zeno_cli/outreach.py +456 -0
  22. zeno_cli/version.py +67 -0
  23. zeno_cli-0.3.4.dist-info/METADATA +161 -0
  24. zeno_cli-0.3.4.dist-info/RECORD +69 -0
  25. zeno_cli-0.3.4.dist-info/WHEEL +4 -0
  26. zeno_cli-0.3.4.dist-info/entry_points.txt +4 -0
  27. zeno_core/__init__.py +67 -0
  28. zeno_core/analytics.py +193 -0
  29. zeno_core/rtlx_s.py +460 -0
  30. zeno_core/streak.py +178 -0
  31. zeno_core/tlx_s.py +192 -0
  32. zeno_sdk/__init__.py +6 -0
  33. zeno_sdk/_generated/__init__.py +6 -0
  34. zeno_sdk/_generated/client.py +819 -0
  35. zeno_sdk/_migrations/alembic/env.py +33 -0
  36. zeno_sdk/_migrations/alembic/script.py.mako +18 -0
  37. zeno_sdk/_migrations/alembic/versions/0001_initial.py +79 -0
  38. zeno_sdk/_migrations/alembic/versions/0002_cognition_samples.py +53 -0
  39. zeno_sdk/_migrations/alembic/versions/0003_cognition_drivers.py +41 -0
  40. zeno_sdk/_migrations/alembic/versions/0004_transcript_intelligence.py +248 -0
  41. zeno_sdk/_migrations/alembic.ini +35 -0
  42. zeno_sdk/_runtime.py +12 -0
  43. zeno_sdk/adapters/__init__.py +15 -0
  44. zeno_sdk/adapters/anthropic.py +5 -0
  45. zeno_sdk/adapters/claude_code.py +5 -0
  46. zeno_sdk/adapters/crewai.py +5 -0
  47. zeno_sdk/adapters/langgraph.py +5 -0
  48. zeno_sdk/adapters/openai.py +5 -0
  49. zeno_sdk/auth.py +25 -0
  50. zeno_sdk/client.py +87 -0
  51. zeno_sdk/config.py +61 -0
  52. zeno_sdk/daemon.py +72 -0
  53. zeno_sdk/privacy.py +46 -0
  54. zeno_sdk/session.py +179 -0
  55. zeno_sdk/storage.py +487 -0
  56. zeno_sdk/types/__init__.py +121 -0
  57. zeno_session_intel/__init__.py +19 -0
  58. zeno_session_intel/analytics.py +588 -0
  59. zeno_session_intel/compression.py +123 -0
  60. zeno_session_intel/ingest.py +376 -0
  61. zeno_session_intel/model.py +129 -0
  62. zeno_session_intel/parsers/__init__.py +31 -0
  63. zeno_session_intel/parsers/claude_code.py +169 -0
  64. zeno_session_intel/parsers/codex.py +265 -0
  65. zeno_session_intel/parsers/cursor.py +198 -0
  66. zeno_session_intel/prices.py +281 -0
  67. zeno_session_intel/schema.py +277 -0
  68. zeno_session_intel/signals.py +319 -0
  69. zeno_session_intel/taxonomy.py +71 -0
@@ -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