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.
- delos_cli/__init__.py +3 -0
- delos_cli/agent/__init__.py +34 -0
- delos_cli/agent/session.py +111 -0
- delos_cli/agent/tools.py +131 -0
- delos_cli/agent/transport.py +102 -0
- delos_cli/apps/__init__.py +6 -0
- delos_cli/apps/base.py +101 -0
- delos_cli/apps/chat/__init__.py +5 -0
- delos_cli/apps/chat/app.py +149 -0
- delos_cli/apps/chat/commands.py +17 -0
- delos_cli/apps/chat/render.py +188 -0
- delos_cli/apps/chat/replay.py +108 -0
- delos_cli/auth/__init__.py +24 -0
- delos_cli/auth/config.py +282 -0
- delos_cli/auth/mfa.py +120 -0
- delos_cli/auth/oauth.py +336 -0
- delos_cli/auth/token_manager.py +136 -0
- delos_cli/commands/__init__.py +10 -0
- delos_cli/commands/base.py +54 -0
- delos_cli/commands/builtin.py +160 -0
- delos_cli/ctx.py +65 -0
- delos_cli/loop.py +19 -0
- delos_cli/main.py +230 -0
- delos_cli/state.py +28 -0
- delos_cli/tools/__init__.py +20 -0
- delos_cli/tools/edit_content.py +193 -0
- delos_cli/tools/run_shell.py +150 -0
- delos_cli/tools/write_content.py +120 -0
- delos_cli/transport/__init__.py +24 -0
- delos_cli/transport/chats.py +235 -0
- delos_cli/transport/client.py +321 -0
- delos_cli/transport/models.py +19 -0
- delos_cli/ui/__init__.py +6 -0
- delos_cli/ui/chat_picker.py +151 -0
- delos_cli/ui/completer.py +68 -0
- delos_cli/ui/lexer.py +62 -0
- delos_cli/ui/output.py +180 -0
- delos_cli/ui/repl.py +679 -0
- delos_cli/ui/style.py +24 -0
- delos_cli-0.1.0.dist-info/METADATA +104 -0
- delos_cli-0.1.0.dist-info/RECORD +43 -0
- delos_cli-0.1.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|
delos_cli/auth/config.py
ADDED
|
@@ -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
|