querri 1.0.2__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 (64) hide show
  1. querri/__init__.py +51 -0
  2. querri/_auth.py +818 -0
  3. querri/_base_client.py +379 -0
  4. querri/_client.py +395 -0
  5. querri/_config.py +140 -0
  6. querri/_convenience.py +538 -0
  7. querri/_exceptions.py +180 -0
  8. querri/_pagination.py +278 -0
  9. querri/_streaming.py +663 -0
  10. querri/_user_client.py +193 -0
  11. querri/_version.py +1 -0
  12. querri/cli/__init__.py +21 -0
  13. querri/cli/_app.py +246 -0
  14. querri/cli/_context.py +216 -0
  15. querri/cli/_image.py +234 -0
  16. querri/cli/_output.py +293 -0
  17. querri/cli/audit.py +68 -0
  18. querri/cli/auth.py +556 -0
  19. querri/cli/chat.py +1255 -0
  20. querri/cli/chats.py +358 -0
  21. querri/cli/dashboards.py +294 -0
  22. querri/cli/embed.py +220 -0
  23. querri/cli/files.py +214 -0
  24. querri/cli/keys.py +304 -0
  25. querri/cli/policies.py +424 -0
  26. querri/cli/projects.py +916 -0
  27. querri/cli/sharing.py +403 -0
  28. querri/cli/sources.py +532 -0
  29. querri/cli/steps.py +123 -0
  30. querri/cli/usage.py +99 -0
  31. querri/cli/users.py +223 -0
  32. querri/cli/views.py +449 -0
  33. querri/cli/whoami.py +78 -0
  34. querri/py.typed +0 -0
  35. querri/resources/__init__.py +1 -0
  36. querri/resources/audit.py +119 -0
  37. querri/resources/dashboards.py +295 -0
  38. querri/resources/embed.py +268 -0
  39. querri/resources/files.py +145 -0
  40. querri/resources/keys.py +191 -0
  41. querri/resources/policies.py +492 -0
  42. querri/resources/projects.py +808 -0
  43. querri/resources/sharing.py +330 -0
  44. querri/resources/sources.py +511 -0
  45. querri/resources/usage.py +106 -0
  46. querri/resources/users.py +303 -0
  47. querri/resources/views.py +347 -0
  48. querri/types/__init__.py +104 -0
  49. querri/types/audit.py +21 -0
  50. querri/types/chat.py +51 -0
  51. querri/types/dashboard.py +51 -0
  52. querri/types/data.py +61 -0
  53. querri/types/embed.py +38 -0
  54. querri/types/file.py +18 -0
  55. querri/types/key.py +36 -0
  56. querri/types/policy.py +105 -0
  57. querri/types/project.py +158 -0
  58. querri/types/sharing.py +19 -0
  59. querri/types/usage.py +39 -0
  60. querri/types/user.py +33 -0
  61. querri-1.0.2.dist-info/METADATA +310 -0
  62. querri-1.0.2.dist-info/RECORD +64 -0
  63. querri-1.0.2.dist-info/WHEEL +4 -0
  64. querri-1.0.2.dist-info/entry_points.txt +2 -0
querri/_auth.py ADDED
@@ -0,0 +1,818 @@
1
+ """Token storage, OAuth browser flow, and credential management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import contextlib
7
+ import json
8
+ import os
9
+ import secrets
10
+ import stat
11
+ import sys
12
+ import webbrowser
13
+ from dataclasses import asdict, dataclass, field
14
+ from http.server import BaseHTTPRequestHandler, HTTPServer
15
+ from pathlib import Path
16
+ from threading import Thread
17
+ from typing import Any
18
+ from urllib.parse import parse_qs, urlencode, urlparse
19
+
20
+ import httpx
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Constants
24
+ # ---------------------------------------------------------------------------
25
+
26
+ WORKOS_CLIENT_ID = "client_01HGV989QK3KDNN4JJNGCD87RH"
27
+ WORKOS_AUTHORIZE_URL = "https://api.workos.com/user_management/authorize"
28
+ OAUTH_TIMEOUT_SECONDS = 120
29
+ OAUTH_CALLBACK_PORT = 11847 # Fixed port for WorkOS redirect URI registration
30
+ REFRESH_BUFFER_SECONDS = 300 # 5 minutes
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # TokenProfile
35
+ # ---------------------------------------------------------------------------
36
+
37
+
38
+ @dataclass
39
+ class TokenProfile:
40
+ """Stored credentials for a single auth profile."""
41
+
42
+ auth_type: str = "jwt" # always "jwt" for OAuth tokens
43
+ access_token: str = ""
44
+ refresh_token: str = ""
45
+ expires_at: str = "" # ISO 8601
46
+ org_id: str = ""
47
+ user_email: str = ""
48
+ user_id: str = ""
49
+ user_name: str = ""
50
+ org_name: str = ""
51
+ host: str = "" # server used for login (used for token refresh)
52
+ all_organizations: dict[str, str] = field(default_factory=dict)
53
+ # Active workspace state
54
+ active_project_id: str = ""
55
+ active_project_name: str = ""
56
+ active_chat_id: str = ""
57
+
58
+ def __repr__(self) -> str:
59
+ """Redact tokens to prevent accidental exposure."""
60
+ return (
61
+ f"TokenProfile(auth_type={self.auth_type!r}, "
62
+ f"access_token='***', refresh_token='***', "
63
+ f"expires_at={self.expires_at!r}, org_id={self.org_id!r}, "
64
+ f"org_name={self.org_name!r}, user_email={self.user_email!r}, "
65
+ f"user_id={self.user_id!r})"
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # TokenStore
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ class TokenStore:
75
+ """Manages persistent token storage in ``~/.querri/tokens.json``.
76
+
77
+ Schema::
78
+
79
+ {
80
+ "profiles": {
81
+ "default": { ...TokenProfile fields... }
82
+ },
83
+ "active_profile": "default"
84
+ }
85
+ """
86
+
87
+ STORE_DIR: Path = Path.home() / ".querri"
88
+ STORE_FILE: Path = STORE_DIR / "tokens.json"
89
+
90
+ def __init__(
91
+ self,
92
+ profiles: dict[str, TokenProfile] | None = None,
93
+ active_profile: str = "default",
94
+ ) -> None:
95
+ self.profiles: dict[str, TokenProfile] = profiles or {}
96
+ self.active_profile = active_profile
97
+
98
+ # -- Persistence --------------------------------------------------------
99
+
100
+ @classmethod
101
+ def load(cls) -> TokenStore:
102
+ """Load the token store from disk.
103
+
104
+ Returns an empty store (without crashing) if the file is missing,
105
+ unreadable, or contains invalid JSON.
106
+ """
107
+ store = cls()
108
+ if not cls.STORE_FILE.exists():
109
+ return store
110
+
111
+ # Warn if file permissions are too open
112
+ try:
113
+ mode = cls.STORE_FILE.stat().st_mode
114
+ if mode & (stat.S_IRWXG | stat.S_IRWXO):
115
+ print(
116
+ f"Warning: {cls.STORE_FILE} has overly permissive permissions "
117
+ f"({oct(mode & 0o777)}). Expected 0600.",
118
+ file=sys.stderr,
119
+ )
120
+ except OSError:
121
+ pass
122
+
123
+ try:
124
+ raw = cls.STORE_FILE.read_text(encoding="utf-8")
125
+ data = json.loads(raw)
126
+ except (OSError, json.JSONDecodeError) as exc:
127
+ print(
128
+ f"Warning: Could not read token store ({exc}). "
129
+ "Starting with empty credentials.",
130
+ file=sys.stderr,
131
+ )
132
+ return store
133
+
134
+ store.active_profile = data.get("active_profile", "default")
135
+ for name, profile_data in data.get("profiles", {}).items():
136
+ if isinstance(profile_data, dict):
137
+ store.profiles[name] = TokenProfile(
138
+ auth_type=profile_data.get("auth_type", "jwt"),
139
+ access_token=profile_data.get("access_token", ""),
140
+ refresh_token=profile_data.get("refresh_token", ""),
141
+ expires_at=profile_data.get("expires_at", ""),
142
+ org_id=profile_data.get("org_id", ""),
143
+ user_email=profile_data.get("user_email", ""),
144
+ user_id=profile_data.get("user_id", ""),
145
+ user_name=profile_data.get("user_name", ""),
146
+ org_name=profile_data.get("org_name", ""),
147
+ host=profile_data.get("host", ""),
148
+ all_organizations=profile_data.get("all_organizations", {}),
149
+ active_project_id=profile_data.get("active_project_id", ""),
150
+ active_project_name=profile_data.get("active_project_name", ""),
151
+ active_chat_id=profile_data.get("active_chat_id", ""),
152
+ )
153
+ return store
154
+
155
+ def save(self) -> None:
156
+ """Atomically write the token store to disk with secure permissions.
157
+
158
+ Writes to a temp file first, then renames to prevent corruption.
159
+ Skips persistence only in explicit CI environments to avoid
160
+ accidentally writing credentials in automated pipelines.
161
+ """
162
+ # Only skip in clearly automated environments. The old isatty() check
163
+ # was too aggressive — it blocked saves from VSCode terminals, tmux,
164
+ # screen, WSL, Claude Code, and any piped context. CI pipelines set
165
+ # a CI env var; that's the right signal.
166
+ if os.environ.get("CI"):
167
+ return
168
+
169
+ # Ensure directory exists with 0700
170
+ os.makedirs(self.STORE_DIR, mode=0o700, exist_ok=True)
171
+
172
+ data = {
173
+ "profiles": {
174
+ name: asdict(profile) for name, profile in self.profiles.items()
175
+ },
176
+ "active_profile": self.active_profile,
177
+ }
178
+ content = json.dumps(data, indent=2)
179
+
180
+ # Atomic write: create temp file with 0600 permissions, then rename
181
+ tmp_path_obj = self.STORE_DIR / ".tokens_tmp"
182
+ try:
183
+ # Create file with restricted permissions — no race window
184
+ fd = os.open(
185
+ str(tmp_path_obj),
186
+ os.O_CREAT | os.O_WRONLY | os.O_TRUNC,
187
+ 0o600,
188
+ )
189
+ try:
190
+ os.write(fd, content.encode("utf-8"))
191
+ finally:
192
+ os.close(fd)
193
+
194
+ # Atomic rename
195
+ os.rename(str(tmp_path_obj), str(self.STORE_FILE))
196
+ except OSError:
197
+ # Clean up temp file on failure
198
+ with contextlib.suppress(OSError):
199
+ tmp_path_obj.unlink(missing_ok=True)
200
+ raise
201
+
202
+ # -- Profile management -------------------------------------------------
203
+
204
+ def get_active_profile(self) -> TokenProfile | None:
205
+ """Return the active profile, or ``None`` if not found."""
206
+ return self.profiles.get(self.active_profile)
207
+
208
+ def save_profile(self, name: str, profile: TokenProfile) -> None:
209
+ """Persist a profile and write to disk."""
210
+ self.profiles[name] = profile
211
+ self.save()
212
+
213
+ def switch_profile(self, name: str) -> None:
214
+ """Change the active profile.
215
+
216
+ Raises:
217
+ KeyError: If the named profile does not exist.
218
+ """
219
+ if name not in self.profiles:
220
+ raise KeyError(
221
+ f"Profile {name!r} not found. Available: {list(self.profiles)}"
222
+ )
223
+ self.active_profile = name
224
+ self.save()
225
+
226
+ def delete_profile(self, name: str) -> None:
227
+ """Remove a profile from the store.
228
+
229
+ Raises:
230
+ KeyError: If the named profile does not exist.
231
+ """
232
+ if name not in self.profiles:
233
+ raise KeyError(f"Profile {name!r} not found.")
234
+ del self.profiles[name]
235
+ # If we deleted the active profile, reset to default or first available
236
+ if self.active_profile == name:
237
+ self.active_profile = next(iter(self.profiles), "default")
238
+ self.save()
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # Token refresh helpers
243
+ # ---------------------------------------------------------------------------
244
+
245
+
246
+ def needs_refresh(profile: TokenProfile) -> bool:
247
+ """Return True if the profile's access token is within 5 minutes of expiry."""
248
+ if not profile.expires_at:
249
+ return True # No expiry info — assume refresh needed
250
+
251
+ try:
252
+ from datetime import datetime, timezone
253
+
254
+ expires = datetime.fromisoformat(profile.expires_at)
255
+ # Ensure timezone-aware comparison
256
+ if expires.tzinfo is None:
257
+ expires = expires.replace(tzinfo=timezone.utc)
258
+ now = datetime.now(timezone.utc)
259
+ remaining = (expires - now).total_seconds()
260
+ return remaining < REFRESH_BUFFER_SECONDS
261
+ except (ValueError, TypeError):
262
+ return True
263
+
264
+
265
+ def refresh_tokens(
266
+ profile: TokenProfile,
267
+ host: str,
268
+ *,
269
+ organization_id: str | None = None,
270
+ ) -> TokenProfile:
271
+ """Refresh an expired access token using the refresh token.
272
+
273
+ Args:
274
+ profile: The profile whose tokens to refresh.
275
+ host: The Querri server host (e.g. ``https://app.querri.com``).
276
+ organization_id: Optional org ID to switch to during refresh.
277
+
278
+ Returns:
279
+ Updated TokenProfile with new tokens.
280
+
281
+ Raises:
282
+ httpx.HTTPStatusError: On server error.
283
+ RuntimeError: If refresh fails.
284
+ """
285
+ url = host.rstrip("/") + "/api/v1/auth/cli/token"
286
+ payload: dict[str, str] = {
287
+ "grant_type": "refresh_token",
288
+ "refresh_token": profile.refresh_token,
289
+ }
290
+ if organization_id:
291
+ payload["organization_id"] = organization_id
292
+ try:
293
+ response = httpx.post(url, json=payload, timeout=30.0)
294
+ response.raise_for_status()
295
+ except httpx.HTTPStatusError as exc:
296
+ raise RuntimeError(
297
+ "Token refresh failed. Please run 'querri auth login' again."
298
+ ) from exc
299
+
300
+ data = response.json()
301
+ profile.access_token = data["access_token"]
302
+ profile.refresh_token = data.get("refresh_token", profile.refresh_token)
303
+
304
+ # Update expiry — try expires_in first, fall back to JWT exp claim
305
+ expires_in = data.get("expires_in")
306
+ if expires_in is not None:
307
+ from datetime import datetime, timedelta, timezone
308
+
309
+ expires = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
310
+ profile.expires_at = expires.isoformat()
311
+ else:
312
+ claims = _decode_jwt_payload(data["access_token"])
313
+ if "exp" in claims:
314
+ from datetime import datetime, timezone
315
+
316
+ profile.expires_at = datetime.fromtimestamp(
317
+ claims["exp"], tz=timezone.utc
318
+ ).isoformat()
319
+
320
+ # Decode JWT for updated user info
321
+ claims = _decode_jwt_payload(profile.access_token)
322
+ if claims:
323
+ profile.user_id = claims.get("sub", profile.user_id)
324
+ profile.org_id = claims.get("org_id", profile.org_id)
325
+ profile.user_email = claims.get("email", profile.user_email)
326
+
327
+ return profile
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # JWT payload decoding (no verification — server already validated)
332
+ # ---------------------------------------------------------------------------
333
+
334
+
335
+ def _decode_jwt_payload(token: str) -> dict[str, Any]:
336
+ """Base64-decode the payload segment of a JWT.
337
+
338
+ No signature verification — the server already validated the token.
339
+ """
340
+ try:
341
+ parts = token.split(".")
342
+ if len(parts) != 3:
343
+ return {}
344
+ # Add padding
345
+ payload = parts[1] + "=="
346
+ decoded = base64.urlsafe_b64decode(payload)
347
+ return json.loads(decoded) # type: ignore[no-any-return]
348
+ except Exception:
349
+ return {}
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # OAuth browser flow
354
+ # ---------------------------------------------------------------------------
355
+
356
+
357
+ class _OAuthCallbackHandler(BaseHTTPRequestHandler):
358
+ """HTTP request handler for the local OAuth callback server."""
359
+
360
+ def do_GET(self) -> None: # noqa: N802
361
+ parsed = urlparse(self.path)
362
+
363
+ if parsed.path != "/callback":
364
+ self.send_response(404)
365
+ self.send_header("Content-Type", "text/plain")
366
+ self.end_headers()
367
+ self.wfile.write(b"Not found")
368
+ return
369
+
370
+ params = parse_qs(parsed.query)
371
+ server: _OAuthCallbackServer = self.server # type: ignore[assignment]
372
+
373
+ # Validate state
374
+ received_state = params.get("state", [""])[0]
375
+ if received_state != server.expected_state:
376
+ self.send_response(400)
377
+ self.send_header("Content-Type", "text/html")
378
+ self.end_headers()
379
+ self.wfile.write(
380
+ b"<html><body><h1>Authentication failed</h1>"
381
+ b"<p>Invalid state parameter. Please try again.</p>"
382
+ b"</body></html>"
383
+ )
384
+ server.error = "State parameter mismatch"
385
+ server.shutdown_flag = True
386
+ return
387
+
388
+ # Check for error from provider
389
+ if "error" in params:
390
+ error_msg = params.get("error_description", params["error"])[0]
391
+ self.send_response(400)
392
+ self.send_header("Content-Type", "text/html")
393
+ self.end_headers()
394
+ self.wfile.write(
395
+ f"<html><body><h1>Authentication failed</h1>"
396
+ f"<p>{error_msg}</p></body></html>".encode()
397
+ )
398
+ server.error = error_msg
399
+ server.shutdown_flag = True
400
+ return
401
+
402
+ # Extract authorization code
403
+ code = params.get("code", [""])[0]
404
+ if not code:
405
+ self.send_response(400)
406
+ self.send_header("Content-Type", "text/html")
407
+ self.end_headers()
408
+ self.wfile.write(
409
+ b"<html><body><h1>Authentication failed</h1>"
410
+ b"<p>No authorization code received.</p>"
411
+ b"</body></html>"
412
+ )
413
+ server.error = "No authorization code received"
414
+ server.shutdown_flag = True
415
+ return
416
+
417
+ # Success
418
+ self.send_response(200)
419
+ self.send_header("Content-Type", "text/html")
420
+ self.end_headers()
421
+ self.wfile.write(
422
+ b"""<!DOCTYPE html>
423
+ <html lang="en">
424
+ <head>
425
+ <meta charset="utf-8">
426
+ <meta name="viewport" content="width=device-width, initial-scale=1">
427
+ <title>Querri CLI - Authenticated</title>
428
+ <link rel="preconnect" href="https://fonts.googleapis.com">
429
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
430
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=Poppins:wght@600;700&family=Source+Sans+3:wght@400;600&display=swap"
431
+ rel="stylesheet">
432
+ <link rel="icon" href="https://querri.com/favicon.svg" type="image/svg+xml">
433
+ <style>
434
+ *{margin:0;padding:0;box-sizing:border-box}
435
+ body{
436
+ font-family:'Source Sans 3','Open Sans',-apple-system,sans-serif;
437
+ background:#0e0e12;
438
+ color:#c5c8cb;
439
+ display:flex;align-items:center;justify-content:center;
440
+ min-height:100vh;
441
+ overflow:hidden;
442
+ }
443
+ /* animated grid background */
444
+ body::before{
445
+ content:'';position:fixed;inset:0;z-index:0;
446
+ background-image:
447
+ linear-gradient(rgba(241,90,36,.04) 1px,transparent 1px),
448
+ linear-gradient(90deg,rgba(241,90,36,.04) 1px,transparent 1px);
449
+ background-size:48px 48px;
450
+ animation:gridPulse 8s ease-in-out infinite;
451
+ }
452
+ @keyframes gridPulse{
453
+ 0%,100%{opacity:.5} 50%{opacity:1}
454
+ }
455
+ /* subtle glow behind card */
456
+ body::after{
457
+ content:'';position:fixed;
458
+ width:600px;height:600px;
459
+ top:50%;left:50%;transform:translate(-50%,-50%);
460
+ background:radial-gradient(circle,rgba(241,90,36,.08) 0%,transparent 70%);
461
+ z-index:0;pointer-events:none;
462
+ }
463
+ .card{
464
+ position:relative;z-index:1;
465
+ max-width:640px;width:92vw;
466
+ background:rgba(22,22,28,.85);
467
+ backdrop-filter:blur(20px);
468
+ border:1px solid rgba(255,255,255,.06);
469
+ border-radius:16px;
470
+ padding:48px 52px;
471
+ box-shadow:0 0 80px rgba(241,90,36,.06),0 32px 64px rgba(0,0,0,.5);
472
+ }
473
+ .header{text-align:center;margin-bottom:32px}
474
+ .logo{margin-bottom:24px}
475
+ .logo img{height:28px;filter:brightness(1.1)}
476
+ .status{
477
+ display:inline-flex;align-items:center;gap:8px;
478
+ padding:6px 16px;
479
+ background:rgba(55,151,100,.1);
480
+ border:1px solid rgba(55,151,100,.25);
481
+ border-radius:100px;
482
+ font-family:'JetBrains Mono',monospace;
483
+ font-size:13px;font-weight:500;
484
+ color:#379764;
485
+ margin-bottom:16px;
486
+ }
487
+ .status .dot{
488
+ width:8px;height:8px;border-radius:50%;
489
+ background:#379764;
490
+ box-shadow:0 0 8px rgba(55,151,100,.6);
491
+ animation:pulse 2s ease-in-out infinite;
492
+ }
493
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
494
+ h1{
495
+ font-family:'Poppins',sans-serif;
496
+ font-size:24px;font-weight:700;
497
+ color:#fff;margin-bottom:6px;
498
+ }
499
+ .subtitle{color:#757980;font-size:14px;line-height:1.5}
500
+ /* terminal block */
501
+ .terminal{
502
+ text-align:left;
503
+ background:#0a0a0e;
504
+ border:1px solid rgba(255,255,255,.06);
505
+ border-radius:12px;
506
+ overflow:hidden;
507
+ margin-bottom:20px;
508
+ }
509
+ .terminal-bar{
510
+ display:flex;align-items:center;gap:6px;
511
+ padding:10px 16px;
512
+ background:rgba(255,255,255,.03);
513
+ border-bottom:1px solid rgba(255,255,255,.04);
514
+ }
515
+ .terminal-dot{width:10px;height:10px;border-radius:50%}
516
+ .terminal-dot.r{background:#ff5f57}
517
+ .terminal-dot.y{background:#febc2e}
518
+ .terminal-dot.g{background:#28c840}
519
+ .terminal-title{
520
+ flex:1;text-align:center;
521
+ font-family:'JetBrains Mono',monospace;
522
+ font-size:11px;color:#515357;
523
+ margin-right:42px;
524
+ }
525
+ .terminal-body{padding:20px 20px 24px}
526
+ .cmd{
527
+ padding:6px 0;
528
+ font-family:'JetBrains Mono',monospace;
529
+ font-size:13px;line-height:1.7;
530
+ }
531
+ .cmd .prompt{color:#F15A24;font-weight:700}
532
+ .cmd .command{color:#e4e4e7}
533
+ .cmd .comment{color:#515357;font-size:12px;margin-left:8px}
534
+ .cmd .flag{color:#41A1B6}
535
+ .cmd .string{color:#379764}
536
+ .cmd .arg{color:#757980;font-style:italic}
537
+ .sep{
538
+ border:none;border-top:1px solid rgba(255,255,255,.04);
539
+ margin:8px 0;
540
+ }
541
+ .section-label{
542
+ font-family:'JetBrains Mono',monospace;
543
+ font-size:11px;color:#515357;
544
+ text-transform:uppercase;
545
+ letter-spacing:1.5px;
546
+ margin:12px 0 4px;
547
+ }
548
+ .close-hint{
549
+ text-align:center;font-size:12px;color:#515357;
550
+ font-family:'JetBrains Mono',monospace;
551
+ }
552
+ .fade-in{animation:fadeIn .5s ease-out}
553
+ @keyframes fadeIn{from{opacity:0;transform:translateY(12px)}to{opacity:1}}
554
+ .typing{animation:fadeIn .5s ease-out both}
555
+ .typing:nth-child(2){animation-delay:.05s}
556
+ .typing:nth-child(3){animation-delay:.1s}
557
+ .typing:nth-child(4){animation-delay:.15s}
558
+ .typing:nth-child(5){animation-delay:.2s}
559
+ .typing:nth-child(6){animation-delay:.25s}
560
+ .typing:nth-child(7){animation-delay:.3s}
561
+ .typing:nth-child(8){animation-delay:.35s}
562
+ .typing:nth-child(9){animation-delay:.4s}
563
+ .typing:nth-child(10){animation-delay:.45s}
564
+ .typing:nth-child(11){animation-delay:.5s}
565
+ .typing:nth-child(12){animation-delay:.55s}
566
+ </style>
567
+ </head>
568
+ <body>
569
+ <div class="card fade-in">
570
+ <div class="header">
571
+ <div class="logo">
572
+ <img src="https://querri.com/querri_logo.svg" alt="Querri" height="28">
573
+ </div>
574
+ <div class="status"><span class="dot"></span> authenticated</div>
575
+ <h1>You're in.</h1>
576
+ <p class="subtitle">Return to your terminal and start exploring.</p>
577
+ </div>
578
+
579
+ <div class="terminal">
580
+ <div class="terminal-bar">
581
+ <span class="terminal-dot r"></span>
582
+ <span class="terminal-dot y"></span>
583
+ <span class="terminal-dot g"></span>
584
+ <span class="terminal-title">querri-cli</span>
585
+ </div>
586
+ <div class="terminal-body">
587
+ <div class="section-label typing" style="margin-top:0">verify</div>
588
+ <div class="cmd typing">
589
+ <span class="prompt">$</span>
590
+ <span class="command"> querri </span><span class="command">whoami</span>
591
+ </div>
592
+
593
+ <hr class="sep">
594
+ <div class="section-label typing">create &amp; upload</div>
595
+ <div class="cmd typing">
596
+ <span class="prompt">$</span>
597
+ <span class="command"> querri projects create</span>
598
+ <span class="flag"> --name</span>
599
+ <span class="string"> "Q1 Analysis"</span>
600
+ </div>
601
+ <div class="cmd typing">
602
+ <span class="prompt">$</span>
603
+ <span class="command"> querri file upload</span>
604
+ <span class="string"> sales.csv</span>
605
+ </div>
606
+
607
+ <hr class="sep">
608
+ <div class="section-label typing">chat with your data</div>
609
+ <div class="cmd typing">
610
+ <span class="prompt">$</span>
611
+ <span class="command"> querri chats create</span>
612
+ <span class="arg"> &lt;project-id&gt;</span>
613
+ </div>
614
+ <div class="cmd typing">
615
+ <span class="prompt">$</span>
616
+ <span class="command"> querri chats stream</span>
617
+ <span class="arg"> &lt;project&gt; &lt;chat&gt;</span>
618
+ <span class="flag"> --prompt</span>
619
+ <span class="string"> "summarize revenue by region"</span>
620
+ </div>
621
+
622
+ <hr class="sep">
623
+ <div class="section-label typing">explore</div>
624
+ <div class="cmd typing">
625
+ <span class="prompt">$</span>
626
+ <span class="command"> querri</span>
627
+ <span class="flag"> --help</span>
628
+ </div>
629
+ </div>
630
+ </div>
631
+
632
+ <p class="close-hint">close this tab and return to your terminal</p>
633
+ </div>
634
+ </body>
635
+ </html>"""
636
+ )
637
+ server.auth_code = code
638
+ server.shutdown_flag = True
639
+
640
+ def log_message(self, format: str, *args: Any) -> None:
641
+ """Suppress default request logging."""
642
+ pass
643
+
644
+
645
+ class _OAuthCallbackServer(HTTPServer):
646
+ """Single-use HTTP server for receiving the OAuth callback."""
647
+
648
+ def __init__(self, expected_state: str) -> None:
649
+ # Bind to 127.0.0.1 only, fixed port (must be registered in WorkOS)
650
+ super().__init__(("127.0.0.1", OAUTH_CALLBACK_PORT), _OAuthCallbackHandler)
651
+ self.timeout = OAUTH_TIMEOUT_SECONDS
652
+ self.expected_state = expected_state
653
+ self.auth_code: str = ""
654
+ self.error: str = ""
655
+ self.shutdown_flag: bool = False
656
+
657
+ def serve_until_callback(self) -> None:
658
+ """Handle requests until a callback is received or timeout."""
659
+ while not self.shutdown_flag:
660
+ self.handle_request()
661
+
662
+
663
+ def start_oauth_flow(
664
+ host: str,
665
+ callback: Any = None,
666
+ *,
667
+ organization_id: str | None = None,
668
+ ) -> dict[str, Any]:
669
+ """Run the browser-based OAuth login flow.
670
+
671
+ 1. Starts a local HTTP server on 127.0.0.1:11847 (fixed port)
672
+ 2. Opens the browser to the WorkOS authorize URL
673
+ 3. Waits for the callback with the authorization code
674
+ 4. Exchanges the code for tokens via the Querri server proxy
675
+
676
+ Args:
677
+ host: The Querri server host (e.g. ``https://app.querri.com``).
678
+ callback: Unused — reserved for future progress callbacks.
679
+ organization_id: Optional WorkOS organization ID to scope the login.
680
+
681
+ Returns:
682
+ Dict with ``access_token``, ``refresh_token``, ``expires_at``,
683
+ ``user_id``, ``org_id``, ``user_email``.
684
+
685
+ Raises:
686
+ RuntimeError: If not in an interactive terminal, or if the flow
687
+ fails at any stage.
688
+ """
689
+ if not sys.stdin.isatty():
690
+ raise RuntimeError(
691
+ "OAuth login requires an interactive terminal. "
692
+ "For CI/scripts, use QUERRI_API_KEY instead."
693
+ )
694
+
695
+ # Generate state for CSRF protection
696
+ state = secrets.token_urlsafe(32)
697
+
698
+ # Start local callback server
699
+ server = _OAuthCallbackServer(expected_state=state)
700
+ port = server.server_address[1]
701
+
702
+ redirect_uri = f"http://127.0.0.1:{port}/callback"
703
+
704
+ # Build authorize URL
705
+ client_id = os.environ.get("QUERRI_WORKOS_CLIENT_ID", WORKOS_CLIENT_ID)
706
+ params: dict[str, str] = {
707
+ "client_id": client_id,
708
+ "redirect_uri": redirect_uri,
709
+ "response_type": "code",
710
+ "state": state,
711
+ "provider": "authkit",
712
+ }
713
+ if organization_id:
714
+ params["organization_id"] = organization_id
715
+ authorize_params = urlencode(params)
716
+ authorize_url = f"{WORKOS_AUTHORIZE_URL}?{authorize_params}"
717
+
718
+ # Open browser
719
+ print("Opening browser for authentication...", file=sys.stderr)
720
+ webbrowser.open(authorize_url)
721
+ print(
722
+ f"If your browser didn't open, visit:\n {authorize_url}",
723
+ file=sys.stderr,
724
+ )
725
+ print("Waiting for authentication...", file=sys.stderr)
726
+
727
+ # Wait for callback in a background thread (so timeout works)
728
+ server_thread = Thread(target=server.serve_until_callback, daemon=True)
729
+ server_thread.start()
730
+ server_thread.join(timeout=OAUTH_TIMEOUT_SECONDS)
731
+
732
+ # Clean up server
733
+ server.server_close()
734
+
735
+ if server.error:
736
+ raise RuntimeError(f"Authentication failed: {server.error}")
737
+ if not server.auth_code:
738
+ raise RuntimeError(
739
+ "Authentication timed out. No callback received within "
740
+ f"{OAUTH_TIMEOUT_SECONDS} seconds."
741
+ )
742
+
743
+ # Exchange authorization code for tokens
744
+ token_url = host.rstrip("/") + "/api/v1/auth/cli/token"
745
+ try:
746
+ response = httpx.post(
747
+ token_url,
748
+ json={
749
+ "grant_type": "authorization_code",
750
+ "code": server.auth_code,
751
+ "redirect_uri": redirect_uri,
752
+ },
753
+ timeout=30.0,
754
+ )
755
+ response.raise_for_status()
756
+ except httpx.HTTPStatusError as exc:
757
+ detail = ""
758
+ with contextlib.suppress(Exception):
759
+ detail = exc.response.text[:200]
760
+ raise RuntimeError(
761
+ f"Token exchange failed (HTTP {exc.response.status_code}). {detail}"
762
+ ) from exc
763
+
764
+ data = response.json()
765
+ access_token = data["access_token"]
766
+ refresh_token = data.get("refresh_token", "")
767
+
768
+ # Calculate expiry
769
+ expires_at = ""
770
+ expires_in = data.get("expires_in")
771
+ if expires_in is not None:
772
+ from datetime import datetime, timedelta, timezone
773
+
774
+ expires = datetime.now(timezone.utc) + timedelta(seconds=int(expires_in))
775
+ expires_at = expires.isoformat()
776
+ else:
777
+ # WorkOS may not return expires_in; decode exp from JWT instead
778
+ claims_for_exp = _decode_jwt_payload(data["access_token"])
779
+ if "exp" in claims_for_exp:
780
+ from datetime import datetime, timezone
781
+
782
+ expires_at = datetime.fromtimestamp(
783
+ claims_for_exp["exp"], tz=timezone.utc
784
+ ).isoformat()
785
+
786
+ # Extract user info from response (WorkOS returns user object alongside tokens)
787
+ # Fall back to JWT claims if user object not present
788
+ user_info = data.get("user") or {}
789
+ claims = _decode_jwt_payload(access_token)
790
+
791
+ user_email = user_info.get("email") or claims.get("email", "")
792
+ user_id = user_info.get("id") or claims.get("sub", "")
793
+ org_id = data.get("organization_id") or claims.get("org_id", "")
794
+ org_name = claims.get("urn:querri:organization_name", "")
795
+ first_name = user_info.get("first_name", "")
796
+ last_name = user_info.get("last_name", "")
797
+ full_name = (
798
+ f"{first_name} {last_name}".strip()
799
+ if first_name or last_name
800
+ else claims.get("urn:querri:full_name", "")
801
+ )
802
+
803
+ # org_name from server response (looked up from WorkOS)
804
+ all_organizations = data.get("all_organizations") or {}
805
+ if not org_name and org_id:
806
+ org_name = all_organizations.get(org_id, org_name)
807
+
808
+ return {
809
+ "access_token": access_token,
810
+ "refresh_token": refresh_token,
811
+ "expires_at": expires_at,
812
+ "user_id": user_id,
813
+ "org_id": org_id,
814
+ "org_name": org_name,
815
+ "user_email": user_email,
816
+ "user_name": full_name,
817
+ "all_organizations": all_organizations,
818
+ }