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,235 @@
1
+ """Read/write access to ``app_cli`` + ``app_cli_messages`` via PostgREST.
2
+
3
+ The CLI talks to Supabase directly for picker / replay / row-creation —
4
+ the backend only sees streaming completions and tool-results. This
5
+ module is the entire data-access layer:
6
+
7
+ - :class:`ChatListItem` + :func:`list_recent` — picker rows.
8
+ - :func:`fetch_messages` — replay payload (OpenAI-format).
9
+ - :func:`create_chat` — INSERT a new ``app_cli`` row.
10
+
11
+ All requests go through :meth:`AuthedClient.supabase_get` /
12
+ :meth:`AuthedClient.supabase_post` so token refresh + the single
13
+ connection pool are shared with the rest of the CLI.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from datetime import UTC, datetime
21
+ from typing import TYPE_CHECKING, Any
22
+ from urllib.parse import urlencode
23
+
24
+ from delos_cli import __version__ as _cli_version
25
+
26
+ from .client import HTTP_ERROR_THRESHOLD, TransportError
27
+
28
+ if TYPE_CHECKING:
29
+ from .client import AuthedClient
30
+
31
+ _DEFAULT_LIMIT = 30
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class ChatListItem:
36
+ """One row from ``app_cli`` for the recent-chats picker."""
37
+
38
+ id: str
39
+ name: str | None
40
+ updated_at: datetime
41
+ message_count: int
42
+ summary: str | None
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # Picker — list recent chats
47
+ # ---------------------------------------------------------------------------
48
+
49
+
50
+ async def list_recent(
51
+ http: AuthedClient,
52
+ org_uuid: str,
53
+ *,
54
+ folder: str | None = None,
55
+ limit: int = _DEFAULT_LIMIT,
56
+ ) -> list[ChatListItem]:
57
+ """Return the most-recently-updated ``app_cli`` rows for ``org_uuid``.
58
+
59
+ RLS scopes the result to the signed-in user (CLI conversations are
60
+ personal — see the ``app_cli_owner_policy`` policy). When ``folder``
61
+ is set, the query is also scoped to chats created from that
62
+ working directory; pre-folder rows (``folder IS NULL``) are excluded
63
+ by the equality filter.
64
+ """
65
+ params = {
66
+ "select": "id,name,updated_at,message_count,summary",
67
+ "org_uuid": f"eq.{org_uuid}",
68
+ "order": "updated_at.desc",
69
+ "limit": str(limit),
70
+ }
71
+ if folder is not None:
72
+ params["folder"] = f"eq.{folder}"
73
+ resp = await http.supabase_get(f"/rest/v1/app_cli?{urlencode(params)}")
74
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
75
+ msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
76
+ raise TransportError(msg)
77
+ rows = resp.json()
78
+ return [_row_to_item(r) for r in rows]
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Replay — load every message of one chat
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ async def fetch_messages(http: AuthedClient, chat_id: str) -> list[dict[str, Any]]:
87
+ """Load ``app_cli_messages`` rows for one chat, mapped to OpenAI format.
88
+
89
+ Returned list is ordered by ``seq`` (oldest first), matching what
90
+ :func:`apps.chat.replay.replay_messages` expects.
91
+ """
92
+ params = {
93
+ "select": (
94
+ "seq,role,content,content_parts,tool_call_id,tool_name,tool_calls,metadata"
95
+ ),
96
+ "chat_id": f"eq.{chat_id}",
97
+ "order": "seq.asc",
98
+ }
99
+ resp = await http.supabase_get(f"/rest/v1/app_cli_messages?{urlencode(params)}")
100
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
101
+ msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
102
+ raise TransportError(msg)
103
+ return [_message_row_to_openai(r) for r in (resp.json() or [])]
104
+
105
+
106
+ # ---------------------------------------------------------------------------
107
+ # Row creation — INSERT a fresh chat
108
+ # ---------------------------------------------------------------------------
109
+
110
+
111
+ async def create_chat(
112
+ http: AuthedClient,
113
+ *,
114
+ folder: str,
115
+ name: str | None = None,
116
+ model: str | None = None,
117
+ ) -> str:
118
+ """INSERT a new row in ``app_cli`` and return its id.
119
+
120
+ The CLI generates the UUID client-side so the caller has the id
121
+ immediately (no need to re-read the inserted row). ``user_id`` is
122
+ filled by the DB default (``auth.uid()``); ``model`` falls back to
123
+ the column default (``claude-4.6-sonnet``) when omitted. ``folder``
124
+ is required (the column is ``NOT NULL``) — pass the cwd so the
125
+ picker can scope its recent-chats list.
126
+ """
127
+ chat_id = str(uuid.uuid4())
128
+ body: dict[str, Any] = {
129
+ "id": chat_id,
130
+ "org_uuid": http.cfg.org_uuid,
131
+ "client_version": _cli_version,
132
+ "region": http.cfg.region,
133
+ "folder": folder,
134
+ }
135
+ if name is not None:
136
+ body["name"] = name
137
+ if model is not None:
138
+ body["model"] = model
139
+
140
+ resp = await http.supabase_post("/rest/v1/app_cli", body, prefer="return=minimal")
141
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
142
+ msg = f"failed to create chat: HTTP {resp.status_code}: {resp.text[:400]}"
143
+ raise TransportError(msg)
144
+ return chat_id
145
+
146
+
147
+ # ---------------------------------------------------------------------------
148
+ # Mutations — rename + delete
149
+ # ---------------------------------------------------------------------------
150
+
151
+
152
+ async def rename_chat(http: AuthedClient, chat_id: str, name: str) -> None:
153
+ """PATCH ``app_cli.name`` for one chat.
154
+
155
+ RLS scopes the UPDATE to the signed-in owner; a 0-row UPDATE on a
156
+ chat owned by someone else returns 200 but changes nothing — same
157
+ semantics as Supabase's REST layer everywhere else.
158
+ """
159
+ body = {"name": name}
160
+ resp = await http.supabase_patch(
161
+ f"/rest/v1/app_cli?id=eq.{chat_id}",
162
+ body,
163
+ prefer="return=minimal",
164
+ )
165
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
166
+ msg = f"failed to rename chat: HTTP {resp.status_code}: {resp.text[:400]}"
167
+ raise TransportError(msg)
168
+
169
+
170
+ async def delete_chat(http: AuthedClient, chat_id: str) -> None:
171
+ """DELETE one ``app_cli`` row.
172
+
173
+ ``app_cli_messages`` rows cascade away via the FK constraint; RLS
174
+ again scopes the DELETE to the owner.
175
+ """
176
+ resp = await http.supabase_delete(f"/rest/v1/app_cli?id=eq.{chat_id}")
177
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
178
+ msg = f"failed to delete chat: HTTP {resp.status_code}: {resp.text[:400]}"
179
+ raise TransportError(msg)
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # Internals
184
+ # ---------------------------------------------------------------------------
185
+
186
+
187
+ def _row_to_item(row: dict[str, Any]) -> ChatListItem:
188
+ raw_updated = row.get("updated_at")
189
+ if isinstance(raw_updated, str):
190
+ updated_at = datetime.fromisoformat(raw_updated)
191
+ else:
192
+ updated_at = datetime.fromtimestamp(0, tz=UTC)
193
+
194
+ name = row.get("name")
195
+ summary = row.get("summary")
196
+ raw_count = row.get("message_count")
197
+ message_count = int(raw_count) if isinstance(raw_count, (int, str)) else 0
198
+
199
+ return ChatListItem(
200
+ id=str(row["id"]),
201
+ name=str(name) if isinstance(name, str) and name else None,
202
+ updated_at=updated_at,
203
+ message_count=message_count,
204
+ summary=str(summary) if isinstance(summary, str) and summary else None,
205
+ )
206
+
207
+
208
+ def _message_row_to_openai(row: dict[str, Any]) -> dict[str, Any]:
209
+ """Translate one ``app_cli_messages`` row into an OpenAI-style dict.
210
+
211
+ Mirrors the backend's ``cli_message_to_openai`` so the replay logic
212
+ sees the same shape as a live agent run.
213
+ """
214
+ msg: dict[str, Any] = {"role": row["role"]}
215
+
216
+ parts = row.get("content_parts")
217
+ if parts:
218
+ msg["content"] = parts
219
+ else:
220
+ msg["content"] = row.get("content")
221
+
222
+ if row.get("tool_call_id"):
223
+ msg["tool_call_id"] = row["tool_call_id"]
224
+ if row.get("tool_name"):
225
+ msg["name"] = row["tool_name"]
226
+ if row.get("tool_calls"):
227
+ msg["tool_calls"] = row["tool_calls"]
228
+
229
+ metadata = row.get("metadata") or {}
230
+ if isinstance(metadata, dict):
231
+ for k, v in metadata.items():
232
+ if k not in msg:
233
+ msg[k] = v
234
+
235
+ return msg
@@ -0,0 +1,321 @@
1
+ """AuthedClient — one httpx session, one place where token refresh lives.
2
+
3
+ Every backend + Supabase REST call in the CLI flows through this class so
4
+ token refresh isn't duplicated, and so we keep a single connection pool for
5
+ the lifetime of the REPL.
6
+
7
+ Entry points:
8
+ * :meth:`json_get` — plain JSON GET against ``{api_url}`` (backend)
9
+ * :meth:`sse_post` — SSE POST that yields parsed ``data:`` events
10
+ * :meth:`supabase_post` — POST to ``{supabase_url}`` (e.g. ``/rest/v1/...``)
11
+ * :meth:`supabase_get` — GET to ``{supabase_url}``
12
+
13
+ Each entry point proactively refreshes via the :class:`TokenManager` before
14
+ the first attempt (based on the JWT's ``exp`` claim), and reactively refreshes
15
+ once on 401/403 before giving up with :class:`TransportError`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from typing import TYPE_CHECKING, Any, Self
22
+
23
+ import httpx
24
+
25
+ from delos_cli.auth.oauth import OAuthError, RefreshTokenInvalidError
26
+ from delos_cli.auth.token_manager import TokenManager
27
+
28
+ if TYPE_CHECKING:
29
+ from collections.abc import AsyncIterator
30
+ from types import TracebackType
31
+
32
+ from delos_cli.auth.config import Config
33
+
34
+ HTTP_UNAUTHORIZED = 401
35
+ HTTP_FORBIDDEN = 403
36
+ HTTP_ERROR_THRESHOLD = 400
37
+ DONE_SENTINEL = "[DONE]"
38
+ _DEFAULT_GET_TIMEOUT_S = 10.0
39
+ _DEFAULT_POST_TIMEOUT_S = 30.0
40
+ _AUTH_REJECTED: frozenset[int] = frozenset({HTTP_UNAUTHORIZED, HTTP_FORBIDDEN})
41
+
42
+
43
+ class TransportError(RuntimeError):
44
+ """Raised when a request fails (HTTP error, auth dead after refresh, bad SSE)."""
45
+
46
+
47
+ class _AuthExpiredError(Exception):
48
+ """Internal signal: got a 401/403, caller should force-refresh and retry."""
49
+
50
+
51
+ class AuthedClient:
52
+ """Shared authenticated HTTP client for the REPL lifetime.
53
+
54
+ Use as an async context manager so the underlying connection pool is
55
+ cleanly closed on shutdown.
56
+ """
57
+
58
+ def __init__(self, cfg: Config) -> None:
59
+ """Build the shared httpx client + a :class:`TokenManager` around ``cfg``."""
60
+ self._tokens = TokenManager(cfg)
61
+ self._http = httpx.AsyncClient(timeout=httpx.Timeout(None, connect=10.0))
62
+
63
+ async def __aenter__(self) -> Self:
64
+ """Enter the async context — returns self for ``async with`` binding."""
65
+ return self
66
+
67
+ async def __aexit__(
68
+ self,
69
+ exc_type: type[BaseException] | None,
70
+ exc: BaseException | None,
71
+ tb: TracebackType | None,
72
+ ) -> None:
73
+ """Close the underlying connection pool on exit."""
74
+ _ = exc_type, exc, tb
75
+ await self._http.aclose()
76
+
77
+ @property
78
+ def cfg(self) -> Config:
79
+ """The live ``Config`` (URL properties, org_uuid, etc.)."""
80
+ return self._tokens.cfg
81
+
82
+ @property
83
+ def tokens(self) -> TokenManager:
84
+ """The :class:`TokenManager`. Mainly exposed for tests."""
85
+ return self._tokens
86
+
87
+ # ------------------------------------------------------------------
88
+ # Backend API calls
89
+ # ------------------------------------------------------------------
90
+
91
+ async def json_get(self, path: str) -> dict[str, Any]:
92
+ """GET ``{api_url}{path}``; decode JSON. Force-refresh once on 401/403."""
93
+ return await self.json_request("GET", path, None)
94
+
95
+ async def json_post(
96
+ self, path: str, body: dict[str, Any] | None = None,
97
+ ) -> dict[str, Any]:
98
+ """POST ``{api_url}{path}`` with optional JSON body; decode JSON."""
99
+ return await self.json_request("POST", path, body)
100
+
101
+ async def json_request(
102
+ self,
103
+ method: str,
104
+ path: str,
105
+ body: dict[str, Any] | None,
106
+ ) -> dict[str, Any]:
107
+ """Generic JSON request against the backend.
108
+
109
+ Force-refreshes once on 401/403 and retries. Returns ``{}`` for
110
+ empty bodies (e.g. 204) or non-JSON responses so callers don't have
111
+ to special-case them.
112
+ """
113
+ resp = await self._send_json_request(method, path, body)
114
+ if resp.status_code in _AUTH_REJECTED:
115
+ await self._force_refresh_or_fail()
116
+ resp = await self._send_json_request(method, path, body)
117
+ if resp.status_code in _AUTH_REJECTED:
118
+ msg = "still unauthorized after refresh; run `delos login`"
119
+ raise TransportError(msg)
120
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
121
+ msg = f"HTTP {resp.status_code}: {resp.text[:400]}"
122
+ raise TransportError(msg)
123
+ if not resp.content:
124
+ return {}
125
+ try:
126
+ return resp.json()
127
+ except ValueError:
128
+ return {}
129
+
130
+ async def sse_post(
131
+ self, path: str, body: dict[str, Any],
132
+ ) -> AsyncIterator[dict[str, Any]]:
133
+ """POST ``{api_url}{path}`` → parsed SSE ``data:`` events.
134
+
135
+ Yields parsed JSON dicts. A ``{"type": "[DONE]"}`` dict signals end.
136
+ Force-refreshes once on 401/403 and retries.
137
+ """
138
+ try:
139
+ async for event in self._stream_sse(path, body):
140
+ yield event
141
+ except _AuthExpiredError:
142
+ pass
143
+ else:
144
+ return
145
+
146
+ await self._force_refresh_or_fail()
147
+ try:
148
+ async for event in self._stream_sse(path, body):
149
+ yield event
150
+ except _AuthExpiredError as e:
151
+ msg = "still unauthorized after refresh; run `delos login`"
152
+ raise TransportError(msg) from e
153
+
154
+ # ------------------------------------------------------------------
155
+ # Supabase REST (supabase_url)
156
+ # ------------------------------------------------------------------
157
+
158
+ async def supabase_post(
159
+ self,
160
+ path: str,
161
+ body: dict[str, Any],
162
+ *,
163
+ prefer: str | None = None,
164
+ ) -> httpx.Response:
165
+ """POST ``{supabase_url}{path}``, with Supabase auth headers.
166
+
167
+ Returns the raw :class:`httpx.Response` so callers can inspect
168
+ status (useful for upsert/409 handling); non-auth failures are NOT
169
+ raised here. Force-refreshes once on 401/403.
170
+ """
171
+ return await self._supabase_request("POST", path, body=body, prefer=prefer)
172
+
173
+ async def supabase_get(self, path: str) -> httpx.Response:
174
+ """GET ``{supabase_url}{path}`` with Supabase auth headers. Raw response."""
175
+ return await self._supabase_request("GET", path)
176
+
177
+ async def supabase_patch(
178
+ self,
179
+ path: str,
180
+ body: dict[str, Any],
181
+ *,
182
+ prefer: str | None = None,
183
+ ) -> httpx.Response:
184
+ """PATCH ``{supabase_url}{path}`` with Supabase auth headers."""
185
+ return await self._supabase_request("PATCH", path, body=body, prefer=prefer)
186
+
187
+ async def supabase_delete(self, path: str) -> httpx.Response:
188
+ """DELETE ``{supabase_url}{path}`` with Supabase auth headers."""
189
+ return await self._supabase_request("DELETE", path)
190
+
191
+ # ------------------------------------------------------------------
192
+ # Internals
193
+ # ------------------------------------------------------------------
194
+
195
+ async def _send_json_request(
196
+ self,
197
+ method: str,
198
+ path: str,
199
+ body: dict[str, Any] | None,
200
+ ) -> httpx.Response:
201
+ headers = await self._backend_headers()
202
+ kwargs: dict[str, Any] = {
203
+ "headers": headers,
204
+ "timeout": _DEFAULT_GET_TIMEOUT_S if method == "GET" else _DEFAULT_POST_TIMEOUT_S,
205
+ }
206
+ if body is not None:
207
+ kwargs["json"] = body
208
+ headers["Content-Type"] = "application/json"
209
+ return await self._http.request(method, f"{self.cfg.api_url}{path}", **kwargs)
210
+
211
+ async def _stream_sse(
212
+ self, path: str, body: dict[str, Any],
213
+ ) -> AsyncIterator[dict[str, Any]]:
214
+ url = f"{self.cfg.api_url}{path}"
215
+ headers = {
216
+ **(await self._backend_headers()),
217
+ "Accept": "text/event-stream",
218
+ "Content-Type": "application/json",
219
+ }
220
+ async with self._http.stream("POST", url, headers=headers, json=body) as resp:
221
+ if resp.status_code in _AUTH_REJECTED:
222
+ raise _AuthExpiredError
223
+ if resp.status_code >= HTTP_ERROR_THRESHOLD:
224
+ text = (await resp.aread()).decode(errors="replace")
225
+ msg = f"HTTP {resp.status_code}: {text[:400]}"
226
+ raise TransportError(msg)
227
+
228
+ buffer = ""
229
+ async for chunk in resp.aiter_text():
230
+ buffer += chunk
231
+ while "\n\n" in buffer:
232
+ raw, buffer = buffer.split("\n\n", 1)
233
+ event = _parse_sse_event(raw)
234
+ if event is None:
235
+ continue
236
+ yield event
237
+ if event.get("type") == DONE_SENTINEL:
238
+ return
239
+
240
+ async def _supabase_request(
241
+ self,
242
+ method: str,
243
+ path: str,
244
+ *,
245
+ body: dict[str, Any] | None = None,
246
+ prefer: str | None = None,
247
+ ) -> httpx.Response:
248
+ """Send a Supabase REST request, refreshing once on 401/403.
249
+
250
+ Generic across POST / GET / PATCH / DELETE — the per-verb public
251
+ wrappers just delegate here. Returns the raw response so callers
252
+ can inspect status codes (e.g. 409 on upsert race).
253
+ """
254
+ resp = await self._send_supabase_request(method, path, body, prefer)
255
+ if resp.status_code in _AUTH_REJECTED:
256
+ await self._force_refresh_or_fail()
257
+ resp = await self._send_supabase_request(method, path, body, prefer)
258
+ return resp
259
+
260
+ async def _send_supabase_request(
261
+ self,
262
+ method: str,
263
+ path: str,
264
+ body: dict[str, Any] | None,
265
+ prefer: str | None,
266
+ ) -> httpx.Response:
267
+ headers = await self._supabase_headers()
268
+ if prefer:
269
+ headers["Prefer"] = prefer
270
+ timeout = _DEFAULT_GET_TIMEOUT_S if method == "GET" else _DEFAULT_POST_TIMEOUT_S
271
+ kwargs: dict[str, Any] = {"headers": headers, "timeout": timeout}
272
+ if body is not None:
273
+ kwargs["json"] = body
274
+ return await self._http.request(
275
+ method, f"{self.cfg.supabase_url}{path}", **kwargs,
276
+ )
277
+
278
+ async def _backend_headers(self) -> dict[str, str]:
279
+ return {"Authorization": f"Bearer {await self._tokens.access_token()}"}
280
+
281
+ async def _supabase_headers(self) -> dict[str, str]:
282
+ return {
283
+ "apikey": self.cfg.supabase_anon_key,
284
+ "Authorization": f"Bearer {await self._tokens.access_token()}",
285
+ "Content-Type": "application/json",
286
+ }
287
+
288
+ async def _force_refresh_or_fail(self) -> None:
289
+ """Translate token-refresh errors into :class:`TransportError`."""
290
+ try:
291
+ await self._tokens.force_refresh()
292
+ except RefreshTokenInvalidError as e:
293
+ msg = f"session expired ({e}); run `delos login`"
294
+ raise TransportError(msg) from e
295
+ except OAuthError as e:
296
+ msg = f"token refresh failed ({e}); try again or run `delos login`"
297
+ raise TransportError(msg) from e
298
+
299
+
300
+ def _parse_sse_event(raw: str) -> dict[str, Any] | None:
301
+ """Parse one SSE event block into a JSON dict.
302
+
303
+ Handles the ``data: <payload>`` form, including the ``[DONE]`` sentinel.
304
+ Returns ``None`` for keepalive/comment lines.
305
+ """
306
+ data_lines = [
307
+ line[5:].lstrip() if line.startswith("data:") else ""
308
+ for line in raw.splitlines()
309
+ if line.startswith("data:")
310
+ ]
311
+ if not data_lines:
312
+ return None
313
+ payload = "\n".join(data_lines).strip()
314
+ if not payload:
315
+ return None
316
+ if payload == DONE_SENTINEL:
317
+ return {"type": DONE_SENTINEL}
318
+ try:
319
+ return json.loads(payload)
320
+ except json.JSONDecodeError:
321
+ return {"type": "parse-error", "raw": payload}
@@ -0,0 +1,19 @@
1
+ """Thin wrapper around ``GET /client/models/{org_uuid}``.
2
+
3
+ Mirrors the data the web frontend uses for its chat model picker —
4
+ filtered server-side by sigma flag, deployment plan, and config fallbacks.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ if TYPE_CHECKING:
12
+ from .client import AuthedClient
13
+
14
+
15
+ async def fetch_models(client: AuthedClient) -> list[str]:
16
+ """Return model ids available to the signed-in user/org."""
17
+ path = f"/client/models/{client.cfg.org_uuid}"
18
+ payload = await client.json_get(path)
19
+ return [m["id"] for m in payload.get("models", [])]
@@ -0,0 +1,6 @@
1
+ """UI layer: REPL Application, output buffer, completer, lexer, style."""
2
+
3
+ from .output import OutputBuffer
4
+ from .repl import run as run_repl
5
+
6
+ __all__ = ["OutputBuffer", "run_repl"]