delos-cli 0.1.0__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 (43) hide show
  1. delos_cli/__init__.py +3 -0
  2. delos_cli/agent/__init__.py +34 -0
  3. delos_cli/agent/session.py +111 -0
  4. delos_cli/agent/tools.py +131 -0
  5. delos_cli/agent/transport.py +102 -0
  6. delos_cli/apps/__init__.py +6 -0
  7. delos_cli/apps/base.py +101 -0
  8. delos_cli/apps/chat/__init__.py +5 -0
  9. delos_cli/apps/chat/app.py +149 -0
  10. delos_cli/apps/chat/commands.py +17 -0
  11. delos_cli/apps/chat/render.py +188 -0
  12. delos_cli/apps/chat/replay.py +108 -0
  13. delos_cli/auth/__init__.py +24 -0
  14. delos_cli/auth/config.py +282 -0
  15. delos_cli/auth/mfa.py +120 -0
  16. delos_cli/auth/oauth.py +336 -0
  17. delos_cli/auth/token_manager.py +136 -0
  18. delos_cli/commands/__init__.py +10 -0
  19. delos_cli/commands/base.py +54 -0
  20. delos_cli/commands/builtin.py +160 -0
  21. delos_cli/ctx.py +65 -0
  22. delos_cli/loop.py +19 -0
  23. delos_cli/main.py +230 -0
  24. delos_cli/state.py +28 -0
  25. delos_cli/tools/__init__.py +20 -0
  26. delos_cli/tools/edit_content.py +193 -0
  27. delos_cli/tools/run_shell.py +150 -0
  28. delos_cli/tools/write_content.py +120 -0
  29. delos_cli/transport/__init__.py +24 -0
  30. delos_cli/transport/chats.py +235 -0
  31. delos_cli/transport/client.py +321 -0
  32. delos_cli/transport/models.py +19 -0
  33. delos_cli/ui/__init__.py +6 -0
  34. delos_cli/ui/chat_picker.py +151 -0
  35. delos_cli/ui/completer.py +68 -0
  36. delos_cli/ui/lexer.py +62 -0
  37. delos_cli/ui/output.py +180 -0
  38. delos_cli/ui/repl.py +679 -0
  39. delos_cli/ui/style.py +24 -0
  40. delos_cli-0.1.0.dist-info/METADATA +104 -0
  41. delos_cli-0.1.0.dist-info/RECORD +43 -0
  42. delos_cli-0.1.0.dist-info/WHEEL +4 -0
  43. delos_cli-0.1.0.dist-info/entry_points.txt +2 -0
delos_cli/auth/mfa.py ADDED
@@ -0,0 +1,120 @@
1
+ """Supabase MFA challenge for the CLI login flow.
2
+
3
+ Compagnon's equivalent lives at ``apps/compagnon/src/hooks/use-auth.tsx`` — it
4
+ calls ``supabase.auth.mfa.getAuthenticatorAssuranceLevel()`` then
5
+ ``mfa.challengeAndVerify({factorId, code})`` to upgrade an ``aal1`` session
6
+ (freshly minted by ``/api/oauth/token``'s ``getSessionTokens`` magic-link path)
7
+ to ``aal2``. We replicate that directly against the Supabase auth REST API so
8
+ we don't pull in ``supabase-py`` just for these three calls.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import json
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+
18
+ import httpx
19
+
20
+ HTTP_ERROR_THRESHOLD = 400
21
+
22
+
23
+ class MfaError(RuntimeError):
24
+ """Raised when the MFA upgrade fails (list, challenge, or verify)."""
25
+
26
+
27
+ @dataclass
28
+ class MfaFactor:
29
+ """A verified TOTP factor the user can challenge against."""
30
+
31
+ id: str
32
+ friendly_name: str
33
+
34
+
35
+ @dataclass
36
+ class UpgradedSession:
37
+ """The aal2 session Supabase returns after a successful verify."""
38
+
39
+ access_token: str
40
+ refresh_token: str
41
+ expires_in: int
42
+
43
+
44
+ def decode_aal(access_token: str) -> str:
45
+ """Extract the ``aal`` claim from a Supabase JWT. Defaults to ``aal1``."""
46
+ parts = access_token.split(".")
47
+ if len(parts) < 2: # noqa: PLR2004 — JWT shape is fixed
48
+ return "aal1"
49
+ payload_b64 = parts[1]
50
+ padding = (-len(payload_b64)) % 4
51
+ payload_b64 += "=" * padding
52
+ try:
53
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
54
+ except (ValueError, json.JSONDecodeError):
55
+ return "aal1"
56
+ return str(payload.get("aal", "aal1"))
57
+
58
+
59
+ async def list_verified_factors(
60
+ supabase_url: str, anon_key: str, access_token: str,
61
+ ) -> list[MfaFactor]:
62
+ """Return verified TOTP factors for the current user.
63
+
64
+ Supabase's JS ``mfa.listFactors()`` reads from ``GET /auth/v1/user`` and
65
+ extracts the ``factors`` array — there's no dedicated list endpoint for
66
+ regular users (``GET /auth/v1/factors`` is 405; it's POST-only for enroll).
67
+ """
68
+ headers = {"apikey": anon_key, "Authorization": f"Bearer {access_token}"}
69
+ async with httpx.AsyncClient(timeout=30.0) as http:
70
+ resp = await http.get(f"{supabase_url}/auth/v1/user", headers=headers)
71
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
72
+ msg = f"fetch user failed: HTTP {resp.status_code}: {resp.text[:200]}"
73
+ raise MfaError(msg)
74
+ user = resp.json()
75
+ factors_raw: list[dict[str, Any]] = user.get("factors") or []
76
+ return [
77
+ MfaFactor(id=str(f["id"]), friendly_name=str(f.get("friendly_name") or "TOTP"))
78
+ for f in factors_raw
79
+ if f.get("status") == "verified" and f.get("factor_type") == "totp"
80
+ ]
81
+
82
+
83
+ async def challenge_and_verify(
84
+ supabase_url: str,
85
+ anon_key: str,
86
+ access_token: str,
87
+ factor_id: str,
88
+ code: str,
89
+ ) -> UpgradedSession:
90
+ """Run Supabase's challenge + verify flow, returning fresh aal2 tokens."""
91
+ headers = {
92
+ "apikey": anon_key,
93
+ "Authorization": f"Bearer {access_token}",
94
+ "Content-Type": "application/json",
95
+ }
96
+ base = f"{supabase_url}/auth/v1/factors/{factor_id}"
97
+ async with httpx.AsyncClient(timeout=30.0) as http:
98
+ chal = await http.post(f"{base}/challenge", headers=headers)
99
+ if chal.status_code >= HTTP_ERROR_THRESHOLD:
100
+ msg = f"challenge failed: HTTP {chal.status_code}: {chal.text[:200]}"
101
+ raise MfaError(msg)
102
+ challenge_id = chal.json().get("id")
103
+ if not challenge_id:
104
+ msg = f"challenge response missing id: {chal.text[:200]}"
105
+ raise MfaError(msg)
106
+
107
+ verify = await http.post(
108
+ f"{base}/verify",
109
+ headers=headers,
110
+ json={"challenge_id": challenge_id, "code": code},
111
+ )
112
+ if verify.status_code >= HTTP_ERROR_THRESHOLD:
113
+ msg = f"verify failed: HTTP {verify.status_code}: {verify.text[:200]}"
114
+ raise MfaError(msg)
115
+ data = verify.json()
116
+ return UpgradedSession(
117
+ access_token=str(data["access_token"]),
118
+ refresh_token=str(data["refresh_token"]),
119
+ expires_in=int(data.get("expires_in", 3600)),
120
+ )
@@ -0,0 +1,336 @@
1
+ """OAuth PKCE loopback flow for `delos login`.
2
+
3
+ Reuses the same `/authorize` + `/api/oauth/token` endpoints that the compagnon
4
+ browser extension uses. Steps:
5
+
6
+ 1. Register a `Delos CLI` OAuth client via RFC 7591 dynamic registration
7
+ (cached per-config so subsequent logins skip this step).
8
+ 2. Generate a PKCE S256 verifier/challenge pair.
9
+ 3. Spin up a one-shot HTTP server on 127.0.0.1:<free_port>/callback.
10
+ 4. Open the browser to {web}/authorize with offline_access scope.
11
+ 5. Catch the redirect, exchange the code at /api/oauth/token, persist tokens.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import base64
18
+ import contextlib
19
+ import hashlib
20
+ import http.server
21
+ import json
22
+ import secrets
23
+ import socket
24
+ import threading
25
+ import time
26
+ import webbrowser
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Any, ClassVar
29
+ from urllib.parse import parse_qs, urlencode, urlparse
30
+
31
+ import httpx
32
+
33
+ from .config import Config, Tokens
34
+ from .mfa import (
35
+ MfaError,
36
+ challenge_and_verify,
37
+ decode_aal,
38
+ list_verified_factors,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ from collections.abc import Awaitable, Callable
43
+
44
+ from .mfa import MfaFactor
45
+
46
+ REDIRECT_PATH = "/callback"
47
+ CALLBACK_TIMEOUT_SECONDS = 600
48
+ DEFAULT_EXPIRES_IN = 3600
49
+ HTTP_ERROR_THRESHOLD = 400
50
+
51
+ # Canonical CLI client, pre-registered in oauth_clients.
52
+ # Must match the row inserted by the infra SQL migration.
53
+ CLIENT_ID = "delos-cli"
54
+ LOOPBACK_PORT = 52700
55
+
56
+ _SUCCESS_HTML = (
57
+ b"<!doctype html><html><head><meta charset='utf-8'>"
58
+ b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
59
+ b"<h1>Signed in.</h1><p>You can close this tab.</p></body></html>"
60
+ )
61
+ _ERROR_HTML = (
62
+ b"<!doctype html><html><head><meta charset='utf-8'>"
63
+ b"<title>Delos CLI</title></head><body style='font-family:sans-serif;padding:3rem'>"
64
+ b"<h1>Sign-in failed.</h1><p>Check the terminal for details.</p></body></html>"
65
+ )
66
+
67
+
68
+ class OAuthError(RuntimeError):
69
+ """Raised when the OAuth flow fails at any step."""
70
+
71
+
72
+ class RefreshTokenInvalidError(OAuthError):
73
+ """The refresh token itself is invalid/revoked — the user must re-login.
74
+
75
+ Distinguished from transient refresh failures (network, 5xx) so that
76
+ callers can surface a "run ``delos login``" message instead of a retry.
77
+ """
78
+
79
+
80
+ @dataclass
81
+ class LoginResult:
82
+ """Outcome of a successful OAuth handshake."""
83
+
84
+ tokens: Tokens
85
+ user_id: str
86
+ org_uuid: str
87
+ client_id: str
88
+
89
+
90
+ def _pkce_pair() -> tuple[str, str]:
91
+ """Generate an RFC 7636 S256 verifier/challenge pair."""
92
+ verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
93
+ digest = hashlib.sha256(verifier.encode()).digest()
94
+ challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
95
+ return verifier, challenge
96
+
97
+
98
+ def _check_port_free(port: int) -> None:
99
+ """Confirm the loopback port is bindable, with a clear error if not."""
100
+ try:
101
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
102
+ s.bind(("127.0.0.1", port))
103
+ except OSError as e:
104
+ msg = (
105
+ f"loopback port {port} is in use — close whatever is holding it "
106
+ f"(try `lsof -iTCP:{port}`) and re-run `delos login`"
107
+ )
108
+ raise OAuthError(msg) from e
109
+
110
+
111
+ class _CallbackHandler(http.server.BaseHTTPRequestHandler):
112
+ """One-shot request handler: captures `code`/`state` and signals the main thread."""
113
+
114
+ result: ClassVar[dict[str, str]] = {}
115
+ event: ClassVar[threading.Event] = threading.Event()
116
+
117
+ def do_GET(self) -> None:
118
+ parsed = urlparse(self.path)
119
+ if parsed.path != REDIRECT_PATH:
120
+ self.send_response(404)
121
+ self.end_headers()
122
+ return
123
+ params = parse_qs(parsed.query)
124
+ code = params.get("code", [""])[0]
125
+ state = params.get("state", [""])[0]
126
+ error = params.get("error", [""])[0]
127
+ if error or not code:
128
+ self._send_html(400, _ERROR_HTML)
129
+ type(self).result = {"error": error or "missing code"}
130
+ else:
131
+ self._send_html(200, _SUCCESS_HTML)
132
+ type(self).result = {"code": code, "state": state}
133
+ type(self).event.set()
134
+
135
+ def log_message(self, format: str, *args: Any) -> None: # noqa: A002 — stdlib signature
136
+ """Silence the default stderr access log."""
137
+
138
+ def _send_html(self, status: int, body: bytes) -> None:
139
+ self.send_response(status)
140
+ self.send_header("Content-Type", "text/html; charset=utf-8")
141
+ self.send_header("Content-Length", str(len(body)))
142
+ self.end_headers()
143
+ self.wfile.write(body)
144
+
145
+
146
+ async def _exchange_code(
147
+ web_url: str,
148
+ client_id: str,
149
+ redirect_uri: str,
150
+ code: str,
151
+ verifier: str,
152
+ ) -> dict[str, Any]:
153
+ """Swap the authorization code for Supabase tokens at /api/oauth/token."""
154
+ async with httpx.AsyncClient(timeout=30.0) as http:
155
+ resp = await http.post(
156
+ f"{web_url}/api/oauth/token",
157
+ json={
158
+ "grant_type": "authorization_code",
159
+ "code": code,
160
+ "code_verifier": verifier,
161
+ "client_id": client_id,
162
+ "redirect_uri": redirect_uri,
163
+ "scope": "offline_access",
164
+ },
165
+ )
166
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
167
+ msg = f"token exchange failed: HTTP {resp.status_code}: {resp.text[:300]}"
168
+ raise OAuthError(msg)
169
+ return resp.json()
170
+
171
+
172
+ async def run_login_flow(
173
+ cfg: Config,
174
+ on_message: Callable[[str], None],
175
+ prompt_code: Callable[[str], Awaitable[str]],
176
+ ) -> LoginResult:
177
+ """Drive the full PKCE loopback flow — returns tokens on success, raises on failure.
178
+
179
+ If the user has a verified TOTP MFA factor, runs Supabase's challenge+verify
180
+ flow client-side (same trick compagnon uses) to upgrade the fresh ``aal1``
181
+ session to ``aal2`` so RLS ``mfa_enforcement`` policies don't block writes.
182
+ ``prompt_code`` is awaited to get the 6-digit code from the user.
183
+ """
184
+ _check_port_free(LOOPBACK_PORT)
185
+ redirect_uri = f"http://127.0.0.1:{LOOPBACK_PORT}{REDIRECT_PATH}"
186
+ verifier, challenge = _pkce_pair()
187
+ state = secrets.token_urlsafe(16)
188
+ client_id = CLIENT_ID
189
+
190
+ params = {
191
+ "client_id": client_id,
192
+ "redirect_uri": redirect_uri,
193
+ "response_type": "code",
194
+ "code_challenge": challenge,
195
+ "code_challenge_method": "S256",
196
+ "state": state,
197
+ "scope": "offline_access",
198
+ }
199
+ authorize_url = f"{cfg.web_url}/authorize?{urlencode(params)}"
200
+
201
+ _CallbackHandler.result = {}
202
+ _CallbackHandler.event = threading.Event()
203
+ server = http.server.HTTPServer(("127.0.0.1", LOOPBACK_PORT), _CallbackHandler)
204
+ thread = threading.Thread(target=server.handle_request, daemon=True)
205
+ thread.start()
206
+
207
+ on_message(f"Open this URL to sign in:\n {authorize_url}")
208
+ with contextlib.suppress(webbrowser.Error):
209
+ webbrowser.open(authorize_url)
210
+
211
+ loop = asyncio.get_running_loop()
212
+ got_callback = await loop.run_in_executor(
213
+ None, _CallbackHandler.event.wait, CALLBACK_TIMEOUT_SECONDS,
214
+ )
215
+ server.server_close()
216
+
217
+ if not got_callback:
218
+ msg = f"no OAuth callback within {CALLBACK_TIMEOUT_SECONDS}s"
219
+ raise OAuthError(msg)
220
+ result = _CallbackHandler.result
221
+ if "error" in result:
222
+ msg = f"OAuth error: {result['error']}"
223
+ raise OAuthError(msg)
224
+ if result.get("state") != state:
225
+ msg = "OAuth state mismatch (possible CSRF)"
226
+ raise OAuthError(msg)
227
+
228
+ on_message("Exchanging authorization code…")
229
+ token_data = await _exchange_code(
230
+ cfg.web_url, client_id, redirect_uri, result["code"], verifier,
231
+ )
232
+
233
+ access = str(token_data.get("supabase_access_token") or token_data.get("access_token", ""))
234
+ refresh = str(token_data.get("supabase_refresh_token") or token_data.get("refresh_token", ""))
235
+ expires_in = int(token_data.get("expires_in", DEFAULT_EXPIRES_IN))
236
+
237
+ access, refresh, expires_in = await _maybe_upgrade_to_aal2(
238
+ cfg, access, refresh, expires_in, on_message, prompt_code,
239
+ )
240
+
241
+ return LoginResult(
242
+ tokens=Tokens(
243
+ access_token=access,
244
+ refresh_token=refresh,
245
+ expires_at=int(time.time()) + expires_in,
246
+ ),
247
+ user_id=str(token_data.get("user_id", "")),
248
+ org_uuid=str(token_data.get("org_uuid", "")),
249
+ client_id=client_id,
250
+ )
251
+
252
+
253
+ async def _maybe_upgrade_to_aal2(
254
+ cfg: Config,
255
+ access: str,
256
+ refresh: str,
257
+ expires_in: int,
258
+ on_message: Callable[[str], None],
259
+ prompt_code: Callable[[str], Awaitable[str]],
260
+ ) -> tuple[str, str, int]:
261
+ """If the session is aal1 and the user has a TOTP factor, run the challenge flow."""
262
+ if decode_aal(access) == "aal2":
263
+ return access, refresh, expires_in
264
+
265
+ try:
266
+ factors = await list_verified_factors(cfg.supabase_url, cfg.supabase_anon_key, access)
267
+ except MfaError as e:
268
+ on_message(f"warning: couldn't list MFA factors ({e}); continuing at aal1")
269
+ return access, refresh, expires_in
270
+
271
+ if not factors:
272
+ return access, refresh, expires_in
273
+
274
+ factor = _pick_factor(factors, on_message)
275
+ on_message(f"MFA required — challenging factor '{factor.friendly_name}'")
276
+ code = (await prompt_code(f"Enter 6-digit code for {factor.friendly_name}: ")).strip()
277
+ try:
278
+ upgraded = await challenge_and_verify(
279
+ cfg.supabase_url, cfg.supabase_anon_key, access, factor.id, code,
280
+ )
281
+ except MfaError as e:
282
+ msg = f"MFA verification failed: {e}"
283
+ raise OAuthError(msg) from e
284
+ on_message("MFA verified — session upgraded to aal2.")
285
+ return upgraded.access_token, upgraded.refresh_token, upgraded.expires_in
286
+
287
+
288
+ def _pick_factor(factors: list[MfaFactor], on_message: Callable[[str], None]) -> MfaFactor:
289
+ """Pick a factor automatically when there's only one; otherwise print the list and use the first."""
290
+ if len(factors) == 1:
291
+ return factors[0]
292
+ on_message("Multiple TOTP factors found — using the first:")
293
+ for i, f in enumerate(factors):
294
+ on_message(f" [{i}] {f.friendly_name}")
295
+ return factors[0]
296
+
297
+
298
+ HTTP_BAD_REQUEST = 400
299
+
300
+
301
+ async def refresh_tokens(cfg: Config) -> Tokens:
302
+ """Swap the refresh token for a fresh access token at /api/oauth/token.
303
+
304
+ Raises :class:`RefreshTokenInvalidError` when the server reports
305
+ ``invalid_grant`` (token revoked, expired, or already rotated by another
306
+ client) — the caller should prompt for re-login. Other failures raise
307
+ :class:`OAuthError` and are generally transient.
308
+ """
309
+ if not cfg.tokens.refresh_token:
310
+ msg = "no refresh_token stored — run `delos login` again"
311
+ raise RefreshTokenInvalidError(msg)
312
+ async with httpx.AsyncClient(timeout=30.0) as http:
313
+ resp = await http.post(
314
+ f"{cfg.web_url}/api/oauth/token",
315
+ json={
316
+ "grant_type": "refresh_token",
317
+ "refresh_token": cfg.tokens.refresh_token,
318
+ "client_id": CLIENT_ID,
319
+ },
320
+ )
321
+ if resp.status_code == HTTP_BAD_REQUEST:
322
+ with contextlib.suppress(json.JSONDecodeError, ValueError):
323
+ err = resp.json()
324
+ if isinstance(err, dict) and err.get("error") == "invalid_grant":
325
+ msg = str(err.get("error_description") or "refresh token is invalid or expired")
326
+ raise RefreshTokenInvalidError(msg)
327
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
328
+ msg = f"refresh failed: HTTP {resp.status_code}: {resp.text[:300]}"
329
+ raise OAuthError(msg)
330
+ data = resp.json()
331
+ expires_in = int(data.get("expires_in", DEFAULT_EXPIRES_IN))
332
+ return Tokens(
333
+ access_token=str(data["access_token"]),
334
+ refresh_token=str(data.get("refresh_token", cfg.tokens.refresh_token)),
335
+ expires_at=int(time.time()) + expires_in,
336
+ )
@@ -0,0 +1,136 @@
1
+ """Single source of truth for access/refresh tokens at runtime.
2
+
3
+ Models the behavior of Supabase JS's ``autoRefreshToken: true`` — the one
4
+ ``apps/compagnon`` relies on to stay signed in "forever" without ever seeing
5
+ 401s from expired access tokens:
6
+
7
+ * **Proactive refresh**: every time the CLI needs an access token, we check
8
+ the JWT's ``exp`` claim; if we're within ``LEEWAY_SECONDS`` of expiry we
9
+ refresh first and hand back the fresh one.
10
+ * **Reactive refresh**: if the server rejects an otherwise-valid-looking
11
+ token (401/403 — could be an admin revocation or clock skew), callers hit
12
+ :meth:`force_refresh` which bypasses the expiry check.
13
+ * **In-process safety**: an :class:`asyncio.Lock` serializes refreshes so a
14
+ burst of 20 concurrent requests with an expired token triggers exactly one
15
+ refresh, not twenty.
16
+ * **Cross-process safety**: :func:`config_file_lock` + re-read from disk
17
+ inside the critical section means two ``delos`` terminals running at once
18
+ can't each burn the same refresh token against Supabase and lose.
19
+ * **Atomic persistence**: writes go through :func:`save`, which does
20
+ tempfile + ``fsync`` + ``os.replace``.
21
+ * **Typed failure modes**: :class:`RefreshTokenInvalidError` surfaces only
22
+ when the refresh token itself is dead (``invalid_grant``) — callers
23
+ translate that into "run ``delos login``"; anything else is transient.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import base64
30
+ import json
31
+ import time
32
+ from typing import TYPE_CHECKING
33
+
34
+ from .config import Tokens, config_file_lock, load, save
35
+ from .oauth import refresh_tokens
36
+
37
+ if TYPE_CHECKING:
38
+ from .config import Config
39
+
40
+ # Refresh this many seconds before the access token actually expires.
41
+ # Picked to absorb network RTT and clock drift without being so eager we
42
+ # churn refreshes for idle REPLs.
43
+ LEEWAY_SECONDS = 60
44
+
45
+
46
+ class TokenManager:
47
+ """Owns the live ``Config.tokens`` for a single CLI process.
48
+
49
+ One instance per ``AuthedClient``. Not thread-safe (asyncio-only); that's
50
+ fine — the CLI runs one event loop. Concurrency inside the loop and
51
+ across sibling processes is handled explicitly via the locks below.
52
+ """
53
+
54
+ def __init__(self, cfg: Config) -> None:
55
+ """Bind the manager to a ``Config``; mutations land in it in-place."""
56
+ self._cfg = cfg
57
+ self._lock = asyncio.Lock()
58
+
59
+ @property
60
+ def cfg(self) -> Config:
61
+ """The live ``Config`` — URL properties are read directly from it."""
62
+ return self._cfg
63
+
64
+ async def access_token(self) -> str:
65
+ """Return a non-near-expiry access token, refreshing first if needed."""
66
+ if self._near_expiry():
67
+ await self._refresh()
68
+ return self._cfg.tokens.access_token
69
+
70
+ async def force_refresh(self) -> None:
71
+ """Refresh unconditionally — call after the server returned 401/403.
72
+
73
+ Still honors the in-process + cross-process locks and the
74
+ disk-adoption shortcut, so a burst of 401s won't multiply refreshes.
75
+ """
76
+ await self._refresh(force=True)
77
+
78
+ def _near_expiry(self) -> bool:
79
+ """True when the access token's ``exp`` is within LEEWAY of now."""
80
+ exp = _jwt_exp(self._cfg.tokens.access_token) or self._cfg.tokens.expires_at
81
+ if not exp:
82
+ return True # no token, or no exp claim — treat as expired
83
+ return exp - int(time.time()) < LEEWAY_SECONDS
84
+
85
+ async def _refresh(self, *, force: bool = False) -> None:
86
+ async with self._lock:
87
+ # Double-check under the in-process lock: another coroutine may
88
+ # have just refreshed while we awaited the lock.
89
+ if not force and not self._near_expiry():
90
+ return
91
+
92
+ # Cross-process lock. Re-read the file inside it — another CLI
93
+ # may have refreshed and written fresh tokens since we started.
94
+ with config_file_lock():
95
+ disk = load()
96
+ if not force and _is_fresher(disk.tokens, self._cfg.tokens):
97
+ self._cfg.tokens = disk.tokens
98
+ return
99
+
100
+ # We're the one doing the refresh. Use whichever refresh
101
+ # token is most recent (ours if unchanged, or the one from
102
+ # disk if another process wrote a newer pair we missed).
103
+ if _is_fresher(disk.tokens, self._cfg.tokens):
104
+ self._cfg.tokens = disk.tokens
105
+
106
+ # On failure (RefreshTokenInvalidError / OAuthError) we
107
+ # let the exception propagate and intentionally do NOT
108
+ # wipe the on-disk tokens — a stale-in-memory vs.
109
+ # fresh-on-disk mismatch (or a transient backend error)
110
+ # shouldn't cost the user their session. The caller
111
+ # surfaces "run delos login" and the user decides.
112
+ self._cfg.tokens = await refresh_tokens(self._cfg)
113
+ save(self._cfg)
114
+
115
+
116
+ def _jwt_exp(access_token: str) -> int | None:
117
+ """Return the ``exp`` claim (seconds since epoch) from a JWT, or None."""
118
+ parts = access_token.split(".")
119
+ min_jwt_parts = 2
120
+ if len(parts) < min_jwt_parts:
121
+ return None
122
+ payload_b64 = parts[1]
123
+ payload_b64 += "=" * ((-len(payload_b64)) % 4)
124
+ try:
125
+ payload = json.loads(base64.urlsafe_b64decode(payload_b64))
126
+ except (ValueError, json.JSONDecodeError):
127
+ return None
128
+ exp = payload.get("exp")
129
+ if isinstance(exp, int):
130
+ return exp
131
+ return None
132
+
133
+
134
+ def _is_fresher(a: Tokens, b: Tokens) -> bool:
135
+ """True when ``a`` represents a session that will live longer than ``b``."""
136
+ return bool(a.access_token) and a.expires_at > b.expires_at
@@ -0,0 +1,10 @@
1
+ """Command primitives + the global command registry.
2
+
3
+ App-specific commands live under ``apps/<name>/commands.py`` and are
4
+ merged on top of ``GLOBAL_COMMANDS`` by the completer and the loop.
5
+ """
6
+
7
+ from .base import CommandSpec, Quit, registry_from
8
+ from .builtin import GLOBAL_COMMANDS
9
+
10
+ __all__ = ["GLOBAL_COMMANDS", "CommandSpec", "Quit", "registry_from"]
@@ -0,0 +1,54 @@
1
+ """Command primitives: the spec + the control-flow signal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Awaitable, Callable, Iterable
10
+
11
+ from delos_cli.ctx import Ctx
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class CommandSpec:
16
+ """One slash command.
17
+
18
+ ``handler`` may be sync or async. ``complete`` is optional and is called
19
+ by the completer to suggest argument values — it receives the raw string
20
+ that follows the command name (may be empty).
21
+ """
22
+
23
+ name: str
24
+ summary: str
25
+ handler: Callable[[Ctx, str], Awaitable[None] | None]
26
+ aliases: tuple[str, ...] = ()
27
+ complete: Callable[[Ctx, str], Iterable[str]] | None = None
28
+ args_hint: str = ""
29
+
30
+ @property
31
+ def display_name(self) -> str:
32
+ """Command name + optional arg hint for help / toolbar."""
33
+ return f"{self.name} {self.args_hint}".rstrip()
34
+
35
+
36
+ class Quit(BaseException):
37
+ """Control-flow signal raised by ``/quit`` to unwind the REPL loop.
38
+
39
+ Subclasses ``BaseException`` on purpose: bare ``except Exception`` handlers
40
+ inside command/app code must not swallow it.
41
+ """
42
+
43
+
44
+ def registry_from(specs: Iterable[CommandSpec]) -> dict[str, CommandSpec]:
45
+ """Build a name→spec dict that also covers every alias of every spec."""
46
+ out: dict[str, CommandSpec] = {}
47
+ for spec in specs:
48
+ out[spec.name] = spec
49
+ for alias in spec.aliases:
50
+ out[alias] = spec
51
+ return out
52
+
53
+
54
+ __all__ = ["CommandSpec", "Quit", "registry_from"]