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
@@ -0,0 +1,188 @@
1
+ """Render v6 UI Message Stream events into an :class:`OutputBuffer`.
2
+
3
+ The renderer is a pure translator: events in, buffer mutations out. The
4
+ prompt_toolkit application owns the terminal drawing and reads the
5
+ buffer's ANSI representation on each redraw.
6
+
7
+ A "thinking" spinner lives in the buffer's *live* slot whenever the
8
+ agent is processing but hasn't started emitting text yet. An owned
9
+ asyncio task re-renders the spinner frame periodically so the dots
10
+ animate; the task is cancelled the moment any text or tool event
11
+ arrives.
12
+
13
+ Streaming text also goes through the live slot so Markdown is
14
+ re-rendered on every ``text-delta`` (the user sees formatting appear as
15
+ it's typed). Tool events go straight to history.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import contextlib
22
+ import time
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from rich.markdown import Markdown
26
+ from rich.panel import Panel
27
+ from rich.spinner import Spinner
28
+ from rich.text import Text
29
+
30
+ from delos_cli.agent import ToolRegistry, default_renderer
31
+
32
+ if TYPE_CHECKING:
33
+ from delos_cli.ui.output import OutputBuffer
34
+
35
+
36
+ _SPINNER_INTERVAL_S = 0.08
37
+
38
+
39
+ class V6Renderer:
40
+ """Translate v6 events into :class:`OutputBuffer` operations."""
41
+
42
+ def __init__(
43
+ self,
44
+ output: OutputBuffer,
45
+ registry: ToolRegistry | None = None,
46
+ ) -> None:
47
+ """Bind to the REPL output buffer. ``registry`` controls per-tool rendering."""
48
+ self._output = output
49
+ self._registry = registry or ToolRegistry()
50
+ self._assistant_text = ""
51
+ # toolCallId → toolName, populated from tool-input-available so we can
52
+ # look up the renderer for output / delta events that don't carry the
53
+ # tool name in their payload.
54
+ self._tool_names: dict[str, str] = {}
55
+ self._spinner = Spinner("dots", text=" thinking", style="dim")
56
+ self._thinking = False
57
+ self._anim_task: asyncio.Task[None] | None = None
58
+
59
+ def apply(self, event: dict[str, Any]) -> None:
60
+ """Apply a single v6 event to the output buffer."""
61
+ etype = event.get("type", "")
62
+
63
+ if etype == "start":
64
+ self._assistant_text = ""
65
+ self._show_thinking()
66
+
67
+ elif etype == "start-step":
68
+ # New step after a tool call resolves — go back to "thinking"
69
+ # until the next text or tool event.
70
+ self._show_thinking()
71
+
72
+ elif etype == "text-start":
73
+ self._assistant_text = ""
74
+
75
+ elif etype == "text-delta":
76
+ delta = event.get("delta", "")
77
+ if not delta:
78
+ return
79
+ self._stop_thinking()
80
+ self._assistant_text += delta
81
+ self._output.update_live(Markdown(self._assistant_text))
82
+
83
+ elif etype == "text-end":
84
+ self._end_inflight()
85
+
86
+ elif etype == "tool-input-available":
87
+ self._end_inflight()
88
+ tcid = event.get("toolCallId", "")
89
+ name = event.get("toolName", "")
90
+ if tcid:
91
+ self._tool_names[tcid] = name
92
+ self._render_tool(name, event)
93
+
94
+ elif etype == "tool-output-available":
95
+ self._end_inflight()
96
+ tcid = event.get("toolCallId", "")
97
+ name = self._tool_names.get(tcid, "")
98
+ self._render_tool(name, {**event, "toolName": name})
99
+
100
+ elif etype == "data-tool-output-delta":
101
+ payload = event.get("data") or {}
102
+ tcid = payload.get("toolCallId", "")
103
+ name = self._tool_names.get(tcid, "")
104
+ self._render_tool(name, {**event, "toolName": name, **payload})
105
+
106
+ elif etype == "error":
107
+ self._end_inflight()
108
+ msg = event.get("errorText", "unknown error")
109
+ self._output.append_block(
110
+ Panel(Text(msg, style="red"), title="error", border_style="red"),
111
+ )
112
+
113
+ elif etype == "data-stopped":
114
+ self._end_inflight()
115
+ self._output.append_block(Text("[stopped]", style="dim"))
116
+
117
+ elif etype == "parse-error":
118
+ self._end_inflight()
119
+ raw = event.get("raw", "")[:200]
120
+ self._output.append_block(
121
+ Text(f"[unparseable event] {raw}", style="dim red"),
122
+ )
123
+
124
+ def close(self) -> None:
125
+ """End the turn — cancel animation, finalize any in-flight live block."""
126
+ self._end_inflight()
127
+
128
+ @property
129
+ def assistant_text(self) -> str:
130
+ """The full assistant text accumulated across this turn."""
131
+ return self._assistant_text
132
+
133
+ # ------------------------------------------------------------------
134
+ # Internals
135
+ # ------------------------------------------------------------------
136
+
137
+ def _render_tool(self, tool_name: str, event: dict[str, Any]) -> None:
138
+ """Look up the registered renderer for ``tool_name`` and append its block."""
139
+ renderer = (
140
+ self._registry.renderer_for(tool_name) if tool_name else default_renderer
141
+ )
142
+ rendered = renderer(event)
143
+ if rendered is not None:
144
+ self._output.append_block(rendered)
145
+
146
+ def _show_thinking(self) -> None:
147
+ """Start the spinner animation in the live slot."""
148
+ if self._thinking:
149
+ return
150
+ self._thinking = True
151
+ self._render_spinner_frame()
152
+ # Spawn the animator task lazily — needs a running event loop, which
153
+ # is guaranteed inside the REPL's async context.
154
+ if self._anim_task is None or self._anim_task.done():
155
+ self._anim_task = asyncio.create_task(self._animate())
156
+
157
+ def _stop_thinking(self) -> None:
158
+ """Cancel the spinner animation; leave the live slot untouched."""
159
+ self._thinking = False
160
+ if self._anim_task is not None and not self._anim_task.done():
161
+ self._anim_task.cancel()
162
+ self._anim_task = None
163
+
164
+ def _end_inflight(self) -> None:
165
+ """Close the in-flight live block.
166
+
167
+ - If the spinner was active, drop it (we don't want "thinking" in
168
+ scrollback).
169
+ - Otherwise commit the streaming markdown to history.
170
+ """
171
+ if self._thinking:
172
+ self._stop_thinking()
173
+ self._output.discard_live()
174
+ else:
175
+ self._output.commit_live()
176
+
177
+ def _render_spinner_frame(self) -> None:
178
+ """Push the spinner's current frame to the live slot."""
179
+ # Spinner.render(time) returns a renderable for the frame at that time.
180
+ self._output.update_live(self._spinner.render(time.monotonic()))
181
+
182
+ async def _animate(self) -> None:
183
+ """Re-render the spinner frame every tick until stopped."""
184
+ with contextlib.suppress(asyncio.CancelledError):
185
+ while self._thinking:
186
+ await asyncio.sleep(_SPINNER_INTERVAL_S)
187
+ if self._thinking:
188
+ self._render_spinner_frame()
@@ -0,0 +1,108 @@
1
+ """Render historical ``app_chat.messages`` rows into the :class:`OutputBuffer`.
2
+
3
+ Translates each OpenAI-style row into the same Rich blocks the live
4
+ :class:`V6Renderer` would produce, so resuming a conversation looks
5
+ identical to what the user saw the first time around — including tool
6
+ calls, which go through the app's :class:`ToolRegistry` so any custom
7
+ renderers stay consistent across live and replayed turns.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from rich.markdown import Markdown
16
+ from rich.text import Text
17
+
18
+ from delos_cli.agent import default_renderer
19
+
20
+ if TYPE_CHECKING:
21
+ from delos_cli.ui.output import OutputBuffer
22
+
23
+ from .app import ChatApp
24
+
25
+
26
+ def replay_messages(
27
+ messages: list[dict[str, Any]],
28
+ app: ChatApp,
29
+ output: OutputBuffer,
30
+ ) -> None:
31
+ """Push every message into ``output`` as a finalized block.
32
+
33
+ No-op when ``messages`` is empty. Each row maps to:
34
+
35
+ - ``role=user`` → ``» <text>`` echo (matches what live turns print).
36
+ - ``role=assistant`` → Markdown block for the text + a synthesized
37
+ ``tool-input-available`` event per ``tool_calls`` entry, routed
38
+ through the app's tool registry.
39
+ - ``role=tool`` → synthesized ``tool-output-available`` event,
40
+ also routed through the tool registry.
41
+ """
42
+ if not messages:
43
+ return
44
+ for msg in messages:
45
+ role = msg.get("role")
46
+ if role == "user":
47
+ text = _extract_text(msg.get("content"))
48
+ if text:
49
+ output.print(Text(f"» {text}", style="bold cyan"))
50
+ elif role == "assistant":
51
+ text = _extract_text(msg.get("content"))
52
+ if text:
53
+ output.append_block(Markdown(text))
54
+ for tc in msg.get("tool_calls") or []:
55
+ _replay_tool_call(tc, app, output)
56
+ elif role == "tool":
57
+ _replay_tool_output(msg, app, output)
58
+
59
+
60
+ def _extract_text(content: Any) -> str:
61
+ """Pull plain text out of either a string or a multipart content list."""
62
+ if isinstance(content, str):
63
+ return content
64
+ if isinstance(content, list):
65
+ parts: list[str] = []
66
+ for part in content:
67
+ if isinstance(part, dict) and part.get("type") == "text":
68
+ txt = part.get("text") or ""
69
+ if isinstance(txt, str):
70
+ parts.append(txt)
71
+ return "".join(parts)
72
+ return ""
73
+
74
+
75
+ def _replay_tool_call(tc: dict[str, Any], app: ChatApp, output: OutputBuffer) -> None:
76
+ """Synthesize a ``tool-input-available`` event and dispatch to the registry."""
77
+ fn = tc.get("function") or {}
78
+ args_str = fn.get("arguments") or ""
79
+ try:
80
+ args = json.loads(args_str) if isinstance(args_str, str) and args_str else {}
81
+ except (json.JSONDecodeError, ValueError):
82
+ args = {}
83
+ name = fn.get("name") or ""
84
+ event = {
85
+ "type": "tool-input-available",
86
+ "toolCallId": tc.get("id") or "",
87
+ "toolName": name,
88
+ "input": args,
89
+ }
90
+ renderer = app.tools.renderer_for(name) if name else default_renderer
91
+ rendered = renderer(event)
92
+ if rendered is not None:
93
+ output.append_block(rendered)
94
+
95
+
96
+ def _replay_tool_output(msg: dict[str, Any], app: ChatApp, output: OutputBuffer) -> None:
97
+ """Synthesize a ``tool-output-available`` event for any custom renderer."""
98
+ name = msg.get("name") or ""
99
+ event = {
100
+ "type": "tool-output-available",
101
+ "toolCallId": msg.get("tool_call_id") or "",
102
+ "toolName": name,
103
+ "output": _extract_text(msg.get("content")),
104
+ }
105
+ renderer = app.tools.renderer_for(name) if name else default_renderer
106
+ rendered = renderer(event)
107
+ if rendered is not None:
108
+ output.append_block(rendered)
@@ -0,0 +1,24 @@
1
+ """Auth: persisted config, OAuth PKCE flow, Supabase MFA upgrade."""
2
+
3
+ from .config import (
4
+ Config,
5
+ Tokens,
6
+ available_regions,
7
+ internal_enabled,
8
+ load,
9
+ save,
10
+ )
11
+ from .oauth import LoginResult, OAuthError, refresh_tokens, run_login_flow
12
+
13
+ __all__ = [
14
+ "Config",
15
+ "LoginResult",
16
+ "OAuthError",
17
+ "Tokens",
18
+ "available_regions",
19
+ "internal_enabled",
20
+ "load",
21
+ "refresh_tokens",
22
+ "run_login_flow",
23
+ "save",
24
+ ]
@@ -0,0 +1,282 @@
1
+ """Config persistence for the delos CLI.
2
+
3
+ Stores a region name (not raw URLs) in ~/.config/delos/config.json so that
4
+ URL changes don't require users to re-login. Env vars override the persisted
5
+ choice at runtime:
6
+
7
+ DELOS_REGION=dev delos # one-shot override (DELOS_ENV works too)
8
+ DELOS_API_URL=... # override a region's API base
9
+ DELOS_WEB_URL=... # override a region's web URL
10
+
11
+ For the `dev` region we additionally try to read backend/.env.local from the
12
+ monorepo so each developer's local ngrok tunnel is picked up automatically.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import contextlib
18
+ import fcntl # POSIX only; Windows support skipped for v1
19
+ import json
20
+ import os
21
+ import tempfile
22
+ from contextlib import contextmanager
23
+ from dataclasses import asdict, dataclass, field
24
+ from pathlib import Path
25
+ from typing import IO, TYPE_CHECKING, TypedDict
26
+
27
+ if TYPE_CHECKING:
28
+ from collections.abc import Iterator
29
+
30
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "delos"
31
+ CONFIG_PATH = CONFIG_DIR / "config.json"
32
+ LOCK_PATH = CONFIG_DIR / ".config.lock"
33
+
34
+ DEFAULT_REGION = "eu"
35
+
36
+
37
+ class RegionUrls(TypedDict):
38
+ """Web + API + Supabase endpoints + public anon key for one region."""
39
+
40
+ web: str
41
+ api: str
42
+ supabase_url: str
43
+ supabase_anon_key: str
44
+
45
+
46
+ # Supabase anon keys are public; same values live in apps/web/.env.production.*
47
+ # and are embedded in the web/extension bundles. Safe to ship here.
48
+ _EU_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJyb2xlIjogImFub24iLAogICJpc3MiOiAic3VwYWJhc2UiLAogICJpYXQiOiAxNzEyNTI3MjAwLAogICJleHAiOiAxODcwMjkzNjAwCn0.gO1qyjuG2g4yAZ3u820P9q5CUTQmDiDrHSurZjIePbA"
49
+ _US_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzU5MjY5NjAwLCJleHAiOjE5MTcwMzYwMDB9.VYuiRrB3q9GaR3qsZauOm1oIH_bZ3iuOzmNQfDcqo40"
50
+ _AE_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzY1MTQ4NDAwLCJleHAiOjE5MjI5MTQ4MDB9.TlD6gF-Q5K1drdnh-XPQgXGs410h5NcHJBRgV6aNXrY"
51
+ _STAGING_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.ewogICJyb2xlIjogImFub24iLAogICJpc3MiOiAic3VwYWJhc2UiLAogICJpYXQiOiAxNzEyNTI3MjAwLAogICJleHAiOiAxODcwMjkzNjAwCn0.GDHgmBuu4XivLHq18P3A-sHblw99If9uqW-HChepSb8"
52
+ _DEV_ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0"
53
+
54
+
55
+ PUBLIC_REGIONS: dict[str, RegionUrls] = {
56
+ "eu": {
57
+ "web": "https://app.delos.so",
58
+ "api": "https://app.delos.so/api/v1",
59
+ "supabase_url": "https://db.app.delos.so",
60
+ "supabase_anon_key": _EU_ANON,
61
+ },
62
+ "us": {
63
+ "web": "https://us.delos.so",
64
+ "api": "https://us.delos.so/api/v1",
65
+ "supabase_url": "https://db.app.us.delos.so",
66
+ "supabase_anon_key": _US_ANON,
67
+ },
68
+ "ae": {
69
+ "web": "https://ae.delos.so",
70
+ "api": "https://ae.delos.so/api/v1",
71
+ "supabase_url": "https://db.ae.delos.so",
72
+ "supabase_anon_key": _AE_ANON,
73
+ },
74
+ }
75
+
76
+ INTERNAL_REGIONS: dict[str, RegionUrls] = {
77
+ "dev": {
78
+ "web": "http://localhost:3000",
79
+ "api": "http://localhost:8000",
80
+ "supabase_url": "http://127.0.0.1:54321",
81
+ "supabase_anon_key": _DEV_ANON,
82
+ },
83
+ "staging": {
84
+ "web": "https://beta.delos.so",
85
+ "api": "https://beta.delos.so/api/v1",
86
+ "supabase_url": "https://db.beta.delos.so",
87
+ "supabase_anon_key": _STAGING_ANON,
88
+ },
89
+ }
90
+
91
+
92
+ def internal_enabled() -> bool:
93
+ """Whether dev/staging regions are unlocked for this process.
94
+
95
+ True when either (a) DELOS_INTERNAL=1 is set, or (b) the CLI is running
96
+ from a cosmos-saas monorepo checkout (detected by finding backend/pyproject.toml
97
+ relative to this file — works for editable installs; fails for PyPI installs,
98
+ which is what we want).
99
+ """
100
+ if os.environ.get("DELOS_INTERNAL", "").lower() in {"1", "true", "yes"}:
101
+ return True
102
+ pkg_dir = Path(__file__).resolve().parent
103
+ return (pkg_dir.parent.parent.parent / "backend" / "pyproject.toml").is_file()
104
+
105
+
106
+ def available_regions() -> dict[str, RegionUrls]:
107
+ """Regions the current user is allowed to target."""
108
+ if internal_enabled():
109
+ return {**PUBLIC_REGIONS, **INTERNAL_REGIONS}
110
+ return dict(PUBLIC_REGIONS)
111
+
112
+
113
+ @dataclass
114
+ class Tokens:
115
+ """Supabase access + refresh token pair."""
116
+
117
+ access_token: str = ""
118
+ refresh_token: str = ""
119
+ expires_at: int = 0 # unix timestamp
120
+
121
+
122
+ @dataclass
123
+ class Config:
124
+ """Persisted CLI configuration: which region, who we're signed in as."""
125
+
126
+ region: str = DEFAULT_REGION
127
+ org_uuid: str = ""
128
+ user_id: str = ""
129
+ client_id: str = "" # cached OAuth client_id from /api/oauth/register
130
+ tokens: Tokens = field(default_factory=Tokens)
131
+
132
+ @property
133
+ def api_url(self) -> str:
134
+ """Effective API base URL: env override → dev auto-detect → region default."""
135
+ if env := os.environ.get("DELOS_API_URL"):
136
+ return env.rstrip("/")
137
+ if self.region == "dev":
138
+ detected = _read_backend_env("COSMOS_BACKEND_BASE_URL")
139
+ if detected:
140
+ return detected.rstrip("/")
141
+ return (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["api"].rstrip("/")
142
+
143
+ @property
144
+ def web_url(self) -> str:
145
+ """Effective web base URL: env override → dev auto-detect → region default."""
146
+ if env := os.environ.get("DELOS_WEB_URL"):
147
+ return env.rstrip("/")
148
+ if self.region == "dev":
149
+ detected = _read_backend_env("COSMOS_FRONTEND_BASE_URL")
150
+ if detected:
151
+ return detected.rstrip("/")
152
+ return (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["web"].rstrip("/")
153
+
154
+ @property
155
+ def supabase_url(self) -> str:
156
+ """Effective Supabase auth URL: env override → dev auto-detect → region default."""
157
+ if env := os.environ.get("DELOS_SUPABASE_URL"):
158
+ return env.rstrip("/")
159
+ if self.region == "dev":
160
+ detected = _read_backend_env("COSMOS_SUPABASE_URL")
161
+ if detected:
162
+ return detected.rstrip("/")
163
+ return (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["supabase_url"].rstrip("/")
164
+
165
+ @property
166
+ def supabase_anon_key(self) -> str:
167
+ """Public Supabase anon key for this region."""
168
+ return (PUBLIC_REGIONS | INTERNAL_REGIONS)[self.region]["supabase_anon_key"]
169
+
170
+ @property
171
+ def is_signed_in(self) -> bool:
172
+ """True when we have enough to make authenticated calls."""
173
+ return bool(self.tokens.access_token and self.org_uuid)
174
+
175
+
176
+ def load() -> Config:
177
+ """Load config from disk, apply DELOS_REGION/DELOS_ENV env overrides."""
178
+ cfg = Config()
179
+ if CONFIG_PATH.exists():
180
+ try:
181
+ data = json.loads(CONFIG_PATH.read_text())
182
+ cfg.region = data.get("region", cfg.region)
183
+ cfg.org_uuid = data.get("org_uuid", cfg.org_uuid)
184
+ cfg.user_id = data.get("user_id", cfg.user_id)
185
+ cfg.client_id = data.get("client_id", cfg.client_id)
186
+ tok = data.get("tokens") or {}
187
+ cfg.tokens = Tokens(
188
+ access_token=tok.get("access_token", ""),
189
+ refresh_token=tok.get("refresh_token", ""),
190
+ expires_at=int(tok.get("expires_at", 0)),
191
+ )
192
+ except (json.JSONDecodeError, OSError, ValueError):
193
+ pass
194
+
195
+ region_override = os.environ.get("DELOS_REGION") or os.environ.get("DELOS_ENV")
196
+ if region_override:
197
+ allowed = available_regions()
198
+ if region_override not in allowed:
199
+ known = ", ".join(sorted(allowed))
200
+ msg = f"Unknown region '{region_override}'. Known: {known}"
201
+ raise ValueError(msg)
202
+ cfg.region = region_override
203
+
204
+ if cfg.region not in available_regions():
205
+ cfg.region = DEFAULT_REGION
206
+
207
+ return cfg
208
+
209
+
210
+ def save(cfg: Config) -> Path:
211
+ """Persist config atomically with chmod 0600.
212
+
213
+ Uses a tempfile in the same directory + ``fsync`` + ``os.replace`` so
214
+ readers can never observe a half-written ``config.json``. Callers that
215
+ want cross-process serialization with other writers should hold
216
+ :func:`config_file_lock` around their read-modify-write.
217
+ """
218
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
219
+ payload = json.dumps(asdict(cfg), indent=2)
220
+ fd, tmp_path_str = tempfile.mkstemp(
221
+ prefix=".config-", suffix=".json.tmp", dir=CONFIG_DIR,
222
+ )
223
+ tmp_path = Path(tmp_path_str)
224
+ try:
225
+ with os.fdopen(fd, "w") as f:
226
+ f.write(payload)
227
+ f.flush()
228
+ os.fsync(f.fileno())
229
+ tmp_path.chmod(0o600)
230
+ tmp_path.replace(CONFIG_PATH)
231
+ except Exception:
232
+ with contextlib.suppress(FileNotFoundError):
233
+ tmp_path.unlink()
234
+ raise
235
+ return CONFIG_PATH
236
+
237
+
238
+ @contextmanager
239
+ def config_file_lock() -> Iterator[None]:
240
+ """Advisory cross-process exclusive lock on ``~/.config/delos/.config.lock``.
241
+
242
+ Wrap any read-modify-write sequence on ``config.json`` with this so that
243
+ two concurrently-running ``delos`` processes can't both refresh tokens
244
+ against the same stale refresh_token (whichever loses the race would
245
+ otherwise be kicked out with ``invalid_grant``).
246
+
247
+ POSIX only (uses :mod:`fcntl`). The lockfile is created on first use and
248
+ left in place; only the lock state matters.
249
+ """
250
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
251
+ f: IO[str] = LOCK_PATH.open("a+")
252
+ try:
253
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
254
+ yield
255
+ finally:
256
+ with contextlib.suppress(OSError):
257
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
258
+ f.close()
259
+
260
+
261
+ def _read_backend_env(key: str) -> str | None:
262
+ """Find monorepo root by walking up from CWD, then read a var from backend/.env.local.
263
+
264
+ Returns None if the file isn't found or the key is missing — callers should
265
+ fall back to the region's default URL.
266
+ """
267
+ cwd = Path.cwd()
268
+ for parent in (cwd, *cwd.parents):
269
+ candidate = parent / "backend" / ".env.local"
270
+ if candidate.is_file():
271
+ try:
272
+ for raw in candidate.read_text().splitlines():
273
+ line = raw.strip()
274
+ if not line or line.startswith("#") or "=" not in line:
275
+ continue
276
+ k, _, v = line.partition("=")
277
+ if k.strip() == key:
278
+ return v.strip().strip('"').strip("'")
279
+ except OSError:
280
+ return None
281
+ return None
282
+ return None