delos-cli 1.0.2__tar.gz → 1.0.4__tar.gz
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-1.0.4/.claude/settings.local.json +8 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/.gitignore +10 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/DELOS.md +2 -2
- {delos_cli-1.0.2 → delos_cli-1.0.4}/PKG-INFO +3 -3
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/app.py +20 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/commands.py +34 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/render.py +14 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/replay.py +3 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/config.py +101 -9
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/oauth.py +29 -19
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/token_manager.py +10 -7
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/prompts.py +6 -0
- delos_cli-1.0.4/delos_cli/context_report.py +176 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ctx.py +21 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/main.py +3 -3
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/server.py +48 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/__init__.py +13 -0
- delos_cli-1.0.4/delos_cli/tools/ask_user_input.py +98 -0
- delos_cli-1.0.4/delos_cli/tools/edit_content.py +442 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/explore.py +7 -2
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/run_shell.py +33 -5
- delos_cli-1.0.4/delos_cli/tools/verify.py +347 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/write_content.py +27 -43
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/client.py +18 -2
- delos_cli-1.0.4/delos_cli/ui/banner.py +102 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/chat_picker.py +50 -22
- delos_cli-1.0.4/delos_cli/ui/completer.py +135 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/lexer.py +14 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/output.py +4 -1
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/repl.py +344 -17
- delos_cli-1.0.4/delos_cli/ui/style.py +51 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/pyproject.toml +8 -3
- delos_cli-1.0.2/delos_cli/apps/chat/commands.py.bak +0 -17
- delos_cli-1.0.2/delos_cli/tools/edit_content.py +0 -224
- delos_cli-1.0.2/delos_cli/ui/banner.py +0 -51
- delos_cli-1.0.2/delos_cli/ui/completer.py +0 -68
- delos_cli-1.0.2/delos_cli/ui/style.py +0 -24
- {delos_cli-1.0.2 → delos_cli-1.0.4}/README.md +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/session.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/tools.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/transport.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/agent/turn.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/base.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/chat/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/app.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/commands.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/apps/scribe/tools.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/auth/mfa.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/base.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/commands/builtin.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/git_context.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/loop.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/project_context.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/app.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/confirm.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/protocol.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/serve/rpc.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/state.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/glob_tool.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/grep.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/read.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/task.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/tools/todo_write.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/chats.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/documents.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/integrations.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/transport/models.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/document_picker.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/integrations_picker.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.4}/delos_cli/ui/model_picker.py +0 -0
|
@@ -96,6 +96,12 @@ yarn-error.log*
|
|
|
96
96
|
!apps/vscode/.vscode/
|
|
97
97
|
.zed
|
|
98
98
|
|
|
99
|
+
# versioned editor defaults (format-on-save) — keep personal settings in VS Code user settings
|
|
100
|
+
!/.vscode
|
|
101
|
+
/.vscode/*
|
|
102
|
+
!/.vscode/settings.json
|
|
103
|
+
!/.vscode/extensions.json
|
|
104
|
+
|
|
99
105
|
# contentlayer
|
|
100
106
|
.contentlayer/
|
|
101
107
|
|
|
@@ -138,6 +144,10 @@ apps/mobile/*.p12
|
|
|
138
144
|
apps/mobile/*.key
|
|
139
145
|
apps/mobile/.metro-health-check*
|
|
140
146
|
|
|
147
|
+
# Desktop App (Electron) — build output (node_modules/build already ignored above)
|
|
148
|
+
apps/desktop/dist/
|
|
149
|
+
apps/desktop/release/
|
|
150
|
+
|
|
141
151
|
|
|
142
152
|
.gitnexus
|
|
143
153
|
|
|
@@ -136,8 +136,8 @@ Executed locally on the user's machine; results posted back via `POST /cli/agent
|
|
|
136
136
|
|---|---|---|---|---|
|
|
137
137
|
| `run_shell` | ✓ | ✓ | ✅ | Allowlist for safe commands; otherwise prompts via `ctx.confirm` |
|
|
138
138
|
| `read` | ✓ | ✓ | ✅ | UTF-8 only, line-numbered slice; skips binaries |
|
|
139
|
-
| `write_content` | ✓ | ✓ | ✅ |
|
|
140
|
-
| `edit_content` | ✓ | ✓ | ✅ | Strict find/replace |
|
|
139
|
+
| `write_content` | ✓ | ✓ | ✅ | Creates the file (+ parent dirs) if missing; refuses to clobber non-empty files |
|
|
140
|
+
| `edit_content` | ✓ | ✓ | ✅ | Strict find/replace — single (`old_content`/`new_content`) or batched (`edits: [{old_content, new_content}, ...]`), all-or-nothing |
|
|
141
141
|
| `grep` | ✓ | ✓ | ✅ | ripgrep wrapper (read-only) |
|
|
142
142
|
| `glob` | ✓ | ✓ | ✅ | Glob pattern matching (read-only) |
|
|
143
143
|
| `todo_write` | ✓ | ✓ (silent) | — | Replaces full plan in [TodoStore](cli/delos_cli/state.py); panel above input shows state |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delos-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
|
|
5
5
|
Project-URL: Homepage, https://delos.so
|
|
6
6
|
Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
|
|
@@ -24,8 +24,8 @@ Requires-Dist: httpx>=0.27.0
|
|
|
24
24
|
Requires-Dist: prompt-toolkit>=3.0.47
|
|
25
25
|
Requires-Dist: questionary>=2.0.1
|
|
26
26
|
Requires-Dist: rich>=13.8.0
|
|
27
|
-
Requires-Dist: ripgrep
|
|
28
|
-
Requires-Dist: ripgrep
|
|
27
|
+
Requires-Dist: ripgrep<15,>=14; sys_platform == 'darwin' and platform_machine == 'arm64'
|
|
28
|
+
Requires-Dist: ripgrep<15,>=14; sys_platform == 'linux' and platform_machine == 'x86_64'
|
|
29
29
|
Requires-Dist: typer>=0.12.5
|
|
30
30
|
Description-Content-Type: text/markdown
|
|
31
31
|
|
|
@@ -25,20 +25,26 @@ from delos_cli.apps.replay import replay_messages
|
|
|
25
25
|
from delos_cli.git_context import collect_git_context
|
|
26
26
|
from delos_cli.state import TodoStore
|
|
27
27
|
from delos_cli.tools import (
|
|
28
|
+
handle_ask_user_input,
|
|
28
29
|
handle_edit_content,
|
|
29
30
|
handle_explore,
|
|
30
31
|
handle_glob,
|
|
31
32
|
handle_grep,
|
|
33
|
+
handle_lint_target,
|
|
32
34
|
handle_read,
|
|
33
35
|
handle_run_shell,
|
|
36
|
+
handle_run_tests,
|
|
34
37
|
handle_todo_write,
|
|
35
38
|
handle_write_content,
|
|
39
|
+
render_ask_user_input,
|
|
36
40
|
render_edit_content,
|
|
37
41
|
render_explore,
|
|
38
42
|
render_glob,
|
|
39
43
|
render_grep,
|
|
44
|
+
render_lint_target,
|
|
40
45
|
render_read,
|
|
41
46
|
render_run_shell,
|
|
47
|
+
render_run_tests,
|
|
42
48
|
render_task,
|
|
43
49
|
render_todo_write,
|
|
44
50
|
render_write_content,
|
|
@@ -75,6 +81,8 @@ def default_tool_registry() -> ToolRegistry:
|
|
|
75
81
|
reg.handlers["todo_write"] = handle_todo_write
|
|
76
82
|
# Silent renderer — the permanent panel above the input shows the state.
|
|
77
83
|
reg.renderers["todo_write"] = render_todo_write
|
|
84
|
+
reg.handlers["ask_user_input"] = handle_ask_user_input
|
|
85
|
+
reg.renderers["ask_user_input"] = render_ask_user_input
|
|
78
86
|
# `task` is server-side (no handler needed); the renderer shows the
|
|
79
87
|
# sub-agent's input/output lifecycle in the terminal.
|
|
80
88
|
reg.renderers["task"] = render_task
|
|
@@ -82,6 +90,11 @@ def default_tool_registry() -> ToolRegistry:
|
|
|
82
90
|
# only its report — see delos_cli.tools.explore.
|
|
83
91
|
reg.handlers["explore"] = handle_explore
|
|
84
92
|
reg.renderers["explore"] = render_explore
|
|
93
|
+
# Verification tools — runner/linter auto-detection, see delos_cli.tools.verify.
|
|
94
|
+
reg.handlers["run_tests"] = handle_run_tests
|
|
95
|
+
reg.renderers["run_tests"] = render_run_tests
|
|
96
|
+
reg.handlers["lint_target"] = handle_lint_target
|
|
97
|
+
reg.renderers["lint_target"] = render_lint_target
|
|
85
98
|
return reg
|
|
86
99
|
|
|
87
100
|
|
|
@@ -151,7 +164,13 @@ class ChatApp(App):
|
|
|
151
164
|
|
|
152
165
|
self._current_session = session
|
|
153
166
|
try:
|
|
154
|
-
|
|
167
|
+
# emit_tool_output=True so client tools that render a completion
|
|
168
|
+
# line (explore's ✓) get a synthetic tool-output-available — the
|
|
169
|
+
# backend never sends one for client-side tools. Without it the
|
|
170
|
+
# 🔎 explore line hangs with no closing ✓. Matches the serve.py
|
|
171
|
+
# (VSCode) path. Input-only renderers (read/grep/glob/run_shell)
|
|
172
|
+
# return None on the output event, so nothing double-renders.
|
|
173
|
+
async for event in session.send_turn(body, ctx, emit_tool_output=True):
|
|
155
174
|
yield event
|
|
156
175
|
finally:
|
|
157
176
|
self._current_session = None
|
|
@@ -18,6 +18,7 @@ Two modes:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import contextlib
|
|
21
|
+
from pathlib import Path
|
|
21
22
|
from typing import TYPE_CHECKING, Any
|
|
22
23
|
|
|
23
24
|
from prompt_toolkit.application import get_app
|
|
@@ -25,6 +26,8 @@ from rich.text import Text
|
|
|
25
26
|
|
|
26
27
|
from delos_cli.commands.base import CommandSpec, registry_from
|
|
27
28
|
from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
|
|
29
|
+
from delos_cli.context_report import fetch_context_report, render_report
|
|
30
|
+
from delos_cli.git_context import collect_git_context
|
|
28
31
|
from delos_cli.transport.chats import patch_chat_model
|
|
29
32
|
from delos_cli.transport.client import TransportError
|
|
30
33
|
|
|
@@ -128,4 +131,34 @@ EXECUTE = CommandSpec(
|
|
|
128
131
|
)
|
|
129
132
|
|
|
130
133
|
|
|
131
|
-
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# /context — token breakdown of the next turn's LLM payload
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _handle_context(ctx: Ctx, args: str) -> None:
|
|
140
|
+
"""Fetch the backend context-report for this chat and render it."""
|
|
141
|
+
_ = args
|
|
142
|
+
sink = _sink(ctx)
|
|
143
|
+
git_ctx = await collect_git_context(Path.cwd())
|
|
144
|
+
try:
|
|
145
|
+
report = await fetch_context_report(
|
|
146
|
+
ctx.http,
|
|
147
|
+
ctx.state.conv_id,
|
|
148
|
+
custom_instructions=ctx.custom_instructions or None,
|
|
149
|
+
git_context=git_ctx,
|
|
150
|
+
)
|
|
151
|
+
except TransportError as e:
|
|
152
|
+
sink.print(Text(f"context report failed: {e}", style="red"))
|
|
153
|
+
return
|
|
154
|
+
sink.print(render_report(report))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
CONTEXT = CommandSpec(
|
|
158
|
+
name="/context",
|
|
159
|
+
summary="show what fills the agent's context window (token breakdown)",
|
|
160
|
+
handler=_handle_context,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE, CONTEXT])
|
|
@@ -55,6 +55,10 @@ class V6Renderer:
|
|
|
55
55
|
self._spinner = Spinner("dots", text=" thinking", style="dim")
|
|
56
56
|
self._thinking = False
|
|
57
57
|
self._anim_task: asyncio.Task[None] | None = None
|
|
58
|
+
# Inside a <think>…</think> fence — AgentV2 streams model reasoning
|
|
59
|
+
# inline between literal tag deltas. Reasoning is swallowed (the
|
|
60
|
+
# spinner keeps running) so it never lands in scrollback.
|
|
61
|
+
self._in_think = False
|
|
58
62
|
|
|
59
63
|
def apply(self, event: dict[str, Any]) -> None:
|
|
60
64
|
"""Apply a single v6 event to the output buffer."""
|
|
@@ -76,6 +80,16 @@ class V6Renderer:
|
|
|
76
80
|
delta = event.get("delta", "")
|
|
77
81
|
if not delta:
|
|
78
82
|
return
|
|
83
|
+
# Each <think> / </think> tag arrives as its own delta
|
|
84
|
+
# (agent_v2 injects them around reasoning), so plain equality
|
|
85
|
+
# checks are enough — no tag can be split across deltas.
|
|
86
|
+
if delta == "<think>":
|
|
87
|
+
self._in_think = True
|
|
88
|
+
return
|
|
89
|
+
if self._in_think:
|
|
90
|
+
if delta.startswith("</think>"):
|
|
91
|
+
self._in_think = False
|
|
92
|
+
return
|
|
79
93
|
self._stop_thinking()
|
|
80
94
|
self._assistant_text += delta
|
|
81
95
|
self._output.update_live(Markdown(self._assistant_text))
|
|
@@ -20,6 +20,7 @@ from rich.markdown import Markdown
|
|
|
20
20
|
from rich.text import Text
|
|
21
21
|
|
|
22
22
|
from delos_cli.agent import default_renderer
|
|
23
|
+
from delos_cli.ui.style import user_echo
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
26
|
from delos_cli.apps.base import App
|
|
@@ -49,7 +50,8 @@ def replay_messages(
|
|
|
49
50
|
if role == "user":
|
|
50
51
|
text = _extract_text(msg.get("content"))
|
|
51
52
|
if text:
|
|
52
|
-
output.print(
|
|
53
|
+
output.print(user_echo(text))
|
|
54
|
+
output.print(Text(""))
|
|
53
55
|
elif role == "assistant":
|
|
54
56
|
text = _extract_text(msg.get("content"))
|
|
55
57
|
if text:
|
|
@@ -22,7 +22,7 @@ import tempfile
|
|
|
22
22
|
from contextlib import contextmanager
|
|
23
23
|
from dataclasses import asdict, dataclass, field
|
|
24
24
|
from pathlib import Path
|
|
25
|
-
from typing import IO, TYPE_CHECKING, TypedDict
|
|
25
|
+
from typing import IO, TYPE_CHECKING, Any, TypedDict
|
|
26
26
|
|
|
27
27
|
from delos_cli import _editable_source_dir
|
|
28
28
|
|
|
@@ -121,6 +121,23 @@ class Tokens:
|
|
|
121
121
|
expires_at: int = 0 # unix timestamp
|
|
122
122
|
|
|
123
123
|
|
|
124
|
+
@dataclass
|
|
125
|
+
class Session:
|
|
126
|
+
"""Signed-in identity + token pair for one region.
|
|
127
|
+
|
|
128
|
+
Refresh tokens are region-scoped (each region is a separate Supabase
|
|
129
|
+
project), so sending region A's refresh token to region B's auth server
|
|
130
|
+
yields ``invalid_grant`` and forces a re-login. Keeping one Session per
|
|
131
|
+
region lets ``--region`` / ``DELOS_REGION`` switches round-trip without
|
|
132
|
+
burning the other region's session.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
org_uuid: str = ""
|
|
136
|
+
user_id: str = ""
|
|
137
|
+
client_id: str = ""
|
|
138
|
+
tokens: Tokens = field(default_factory=Tokens)
|
|
139
|
+
|
|
140
|
+
|
|
124
141
|
@dataclass
|
|
125
142
|
class Config:
|
|
126
143
|
"""Persisted CLI configuration: which region, who we're signed in as."""
|
|
@@ -130,6 +147,10 @@ class Config:
|
|
|
130
147
|
user_id: str = ""
|
|
131
148
|
client_id: str = "" # cached OAuth client_id from /api/oauth/register
|
|
132
149
|
tokens: Tokens = field(default_factory=Tokens)
|
|
150
|
+
# Stashed sessions for non-active regions, keyed by region name. The
|
|
151
|
+
# active region's session lives in the flat fields above (kept flat for
|
|
152
|
+
# backward compatibility with older CLI builds reading the same file).
|
|
153
|
+
sessions: dict[str, Session] = field(default_factory=dict)
|
|
133
154
|
|
|
134
155
|
@property
|
|
135
156
|
def api_url(self) -> str:
|
|
@@ -175,6 +196,44 @@ class Config:
|
|
|
175
196
|
return bool(self.tokens.access_token and self.org_uuid)
|
|
176
197
|
|
|
177
198
|
|
|
199
|
+
def _parse_tokens(tok: dict[str, Any]) -> Tokens:
|
|
200
|
+
"""Build a ``Tokens`` from a raw JSON dict, tolerating missing fields."""
|
|
201
|
+
return Tokens(
|
|
202
|
+
access_token=tok.get("access_token", ""),
|
|
203
|
+
refresh_token=tok.get("refresh_token", ""),
|
|
204
|
+
expires_at=int(tok.get("expires_at", 0)),
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _current_session(cfg: Config) -> Session:
|
|
209
|
+
"""Snapshot the active region's flat fields as a ``Session``."""
|
|
210
|
+
return Session(
|
|
211
|
+
org_uuid=cfg.org_uuid,
|
|
212
|
+
user_id=cfg.user_id,
|
|
213
|
+
client_id=cfg.client_id,
|
|
214
|
+
tokens=cfg.tokens,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def set_region(cfg: Config, region: str) -> None:
|
|
219
|
+
"""Switch the active region, swapping sessions instead of leaking tokens.
|
|
220
|
+
|
|
221
|
+
Stashes the current region's session into ``cfg.sessions`` and adopts the
|
|
222
|
+
target region's stashed session (empty if never signed in there). Without
|
|
223
|
+
this swap, a region switch keeps the old region's refresh token and burns
|
|
224
|
+
it against the wrong auth server.
|
|
225
|
+
"""
|
|
226
|
+
if region == cfg.region:
|
|
227
|
+
return
|
|
228
|
+
cfg.sessions[cfg.region] = _current_session(cfg)
|
|
229
|
+
cfg.region = region
|
|
230
|
+
adopted = cfg.sessions.get(region, Session())
|
|
231
|
+
cfg.org_uuid = adopted.org_uuid
|
|
232
|
+
cfg.user_id = adopted.user_id
|
|
233
|
+
cfg.client_id = adopted.client_id
|
|
234
|
+
cfg.tokens = adopted.tokens
|
|
235
|
+
|
|
236
|
+
|
|
178
237
|
def load() -> Config:
|
|
179
238
|
"""Load config from disk, apply DELOS_REGION/DELOS_ENV env overrides."""
|
|
180
239
|
cfg = Config()
|
|
@@ -185,12 +244,15 @@ def load() -> Config:
|
|
|
185
244
|
cfg.org_uuid = data.get("org_uuid", cfg.org_uuid)
|
|
186
245
|
cfg.user_id = data.get("user_id", cfg.user_id)
|
|
187
246
|
cfg.client_id = data.get("client_id", cfg.client_id)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
247
|
+
cfg.tokens = _parse_tokens(data.get("tokens") or {})
|
|
248
|
+
for name, raw in (data.get("sessions") or {}).items():
|
|
249
|
+
if isinstance(raw, dict):
|
|
250
|
+
cfg.sessions[name] = Session(
|
|
251
|
+
org_uuid=raw.get("org_uuid", ""),
|
|
252
|
+
user_id=raw.get("user_id", ""),
|
|
253
|
+
client_id=raw.get("client_id", ""),
|
|
254
|
+
tokens=_parse_tokens(raw.get("tokens") or {}),
|
|
255
|
+
)
|
|
194
256
|
except (json.JSONDecodeError, OSError, ValueError):
|
|
195
257
|
pass
|
|
196
258
|
|
|
@@ -201,10 +263,10 @@ def load() -> Config:
|
|
|
201
263
|
known = ", ".join(sorted(allowed))
|
|
202
264
|
msg = f"Unknown region '{region_override}'. Known: {known}"
|
|
203
265
|
raise ValueError(msg)
|
|
204
|
-
cfg
|
|
266
|
+
set_region(cfg, region_override)
|
|
205
267
|
|
|
206
268
|
if cfg.region not in available_regions():
|
|
207
|
-
cfg
|
|
269
|
+
set_region(cfg, DEFAULT_REGION)
|
|
208
270
|
|
|
209
271
|
return cfg
|
|
210
272
|
|
|
@@ -218,6 +280,9 @@ def save(cfg: Config) -> Path:
|
|
|
218
280
|
:func:`config_file_lock` around their read-modify-write.
|
|
219
281
|
"""
|
|
220
282
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
283
|
+
# Mirror the active region's session into the stash so a later region
|
|
284
|
+
# switch (or an older CLI build rewriting the file) can't lose it.
|
|
285
|
+
cfg.sessions[cfg.region] = _current_session(cfg)
|
|
221
286
|
payload = json.dumps(asdict(cfg), indent=2)
|
|
222
287
|
fd, tmp_path_str = tempfile.mkstemp(
|
|
223
288
|
prefix=".config-",
|
|
@@ -239,6 +304,33 @@ def save(cfg: Config) -> Path:
|
|
|
239
304
|
return CONFIG_PATH
|
|
240
305
|
|
|
241
306
|
|
|
307
|
+
def region_tokens(cfg: Config, region: str) -> Tokens:
|
|
308
|
+
"""Tokens for ``region``: the flat pair if active, else the stashed one."""
|
|
309
|
+
if cfg.region == region:
|
|
310
|
+
return cfg.tokens
|
|
311
|
+
stashed = cfg.sessions.get(region)
|
|
312
|
+
return stashed.tokens if stashed else Tokens()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def save_tokens(cfg: Config) -> Path:
|
|
316
|
+
"""Persist ``cfg``'s token pair for ``cfg.region`` without anything else.
|
|
317
|
+
|
|
318
|
+
Re-reads the file and only updates this region's tokens, so a refresh
|
|
319
|
+
happening in one process (e.g. the VSCode extension's ``delos serve``)
|
|
320
|
+
can't clobber another region's session, flip the file's active region,
|
|
321
|
+
or overwrite identity fields written since. Callers should hold
|
|
322
|
+
:func:`config_file_lock` around their read-refresh-save sequence.
|
|
323
|
+
"""
|
|
324
|
+
disk = load()
|
|
325
|
+
if disk.region == cfg.region:
|
|
326
|
+
disk.tokens = cfg.tokens
|
|
327
|
+
else:
|
|
328
|
+
stashed = disk.sessions.get(cfg.region, Session())
|
|
329
|
+
stashed.tokens = cfg.tokens
|
|
330
|
+
disk.sessions[cfg.region] = stashed
|
|
331
|
+
return save(disk)
|
|
332
|
+
|
|
333
|
+
|
|
242
334
|
@contextmanager
|
|
243
335
|
def config_file_lock() -> Iterator[None]:
|
|
244
336
|
"""Advisory cross-process exclusive lock on ``~/.config/delos/.config.lock``.
|
|
@@ -333,34 +333,44 @@ def _pick_factor(factors: list[MfaFactor], on_message: Callable[[str], None]) ->
|
|
|
333
333
|
|
|
334
334
|
|
|
335
335
|
HTTP_BAD_REQUEST = 400
|
|
336
|
+
HTTP_TOO_MANY_REQUESTS = 429
|
|
337
|
+
HTTP_SERVER_ERROR = 500
|
|
336
338
|
|
|
337
339
|
|
|
338
340
|
async def refresh_tokens(cfg: Config) -> Tokens:
|
|
339
|
-
"""Swap the refresh token for a fresh
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
341
|
+
"""Swap the refresh token for a fresh pair, directly against GoTrue.
|
|
342
|
+
|
|
343
|
+
Our refresh tokens ARE Supabase refresh tokens (the web ``/api/oauth/token``
|
|
344
|
+
refresh grant just proxies to ``auth.refreshSession``), so we talk to
|
|
345
|
+
``{supabase_url}/auth/v1/token`` like every other Supabase client does.
|
|
346
|
+
This keeps the web frontend out of the refresh path — in the dev region
|
|
347
|
+
the Next server may be down or HTTPS-only, and that must not read as an
|
|
348
|
+
auth failure.
|
|
349
|
+
|
|
350
|
+
Raises :class:`RefreshTokenInvalidError` only when GoTrue rejects the
|
|
351
|
+
token itself (4xx) — the caller should prompt for re-login. Everything
|
|
352
|
+
else (network, TLS, 5xx) raises :class:`OAuthError` and is transient.
|
|
345
353
|
"""
|
|
346
354
|
if not cfg.tokens.refresh_token:
|
|
347
355
|
msg = "no refresh_token stored — run `delos login` again"
|
|
348
356
|
raise RefreshTokenInvalidError(msg)
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
"
|
|
354
|
-
"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
)
|
|
358
|
-
|
|
357
|
+
try:
|
|
358
|
+
async with httpx.AsyncClient(timeout=30.0) as http:
|
|
359
|
+
resp = await http.post(
|
|
360
|
+
f"{cfg.supabase_url}/auth/v1/token?grant_type=refresh_token",
|
|
361
|
+
json={"refresh_token": cfg.tokens.refresh_token},
|
|
362
|
+
headers={"apikey": cfg.supabase_anon_key},
|
|
363
|
+
)
|
|
364
|
+
except httpx.HTTPError as e:
|
|
365
|
+
msg = f"refresh failed: {type(e).__name__}: {e}"
|
|
366
|
+
raise OAuthError(msg) from e
|
|
367
|
+
if HTTP_BAD_REQUEST <= resp.status_code < HTTP_SERVER_ERROR and resp.status_code != HTTP_TOO_MANY_REQUESTS:
|
|
368
|
+
detail = "refresh token is invalid or expired"
|
|
359
369
|
with contextlib.suppress(json.JSONDecodeError, ValueError):
|
|
360
370
|
err = resp.json()
|
|
361
|
-
if isinstance(err, dict)
|
|
362
|
-
|
|
363
|
-
|
|
371
|
+
if isinstance(err, dict):
|
|
372
|
+
detail = str(err.get("msg") or err.get("error_description") or detail)
|
|
373
|
+
raise RefreshTokenInvalidError(detail)
|
|
364
374
|
if resp.status_code >= HTTP_ERROR_THRESHOLD:
|
|
365
375
|
msg = f"refresh failed: HTTP {resp.status_code}: {resp.text[:300]}"
|
|
366
376
|
raise OAuthError(msg)
|
|
@@ -31,7 +31,7 @@ import json
|
|
|
31
31
|
import time
|
|
32
32
|
from typing import TYPE_CHECKING
|
|
33
33
|
|
|
34
|
-
from .config import Tokens, config_file_lock, load,
|
|
34
|
+
from .config import Tokens, config_file_lock, load, region_tokens, save_tokens
|
|
35
35
|
from .oauth import refresh_tokens
|
|
36
36
|
|
|
37
37
|
if TYPE_CHECKING:
|
|
@@ -91,17 +91,20 @@ class TokenManager:
|
|
|
91
91
|
|
|
92
92
|
# Cross-process lock. Re-read the file inside it — another CLI
|
|
93
93
|
# may have refreshed and written fresh tokens since we started.
|
|
94
|
+
# Compare against OUR region's tokens: the file's active region
|
|
95
|
+
# can differ (e.g. `delos serve --region X` next to a terminal
|
|
96
|
+
# signed into Y) and cross-region pairs must never be adopted.
|
|
94
97
|
with config_file_lock():
|
|
95
|
-
|
|
96
|
-
if not force and _is_fresher(
|
|
97
|
-
self._cfg.tokens =
|
|
98
|
+
disk_tokens = region_tokens(load(), self._cfg.region)
|
|
99
|
+
if not force and _is_fresher(disk_tokens, self._cfg.tokens):
|
|
100
|
+
self._cfg.tokens = disk_tokens
|
|
98
101
|
return
|
|
99
102
|
|
|
100
103
|
# We're the one doing the refresh. Use whichever refresh
|
|
101
104
|
# token is most recent (ours if unchanged, or the one from
|
|
102
105
|
# disk if another process wrote a newer pair we missed).
|
|
103
|
-
if _is_fresher(
|
|
104
|
-
self._cfg.tokens =
|
|
106
|
+
if _is_fresher(disk_tokens, self._cfg.tokens):
|
|
107
|
+
self._cfg.tokens = disk_tokens
|
|
105
108
|
|
|
106
109
|
# On failure (RefreshTokenInvalidError / OAuthError) we
|
|
107
110
|
# let the exception propagate and intentionally do NOT
|
|
@@ -110,7 +113,7 @@ class TokenManager:
|
|
|
110
113
|
# shouldn't cost the user their session. The caller
|
|
111
114
|
# surfaces "run delos login" and the user decides.
|
|
112
115
|
self._cfg.tokens = await refresh_tokens(self._cfg)
|
|
113
|
-
|
|
116
|
+
save_tokens(self._cfg)
|
|
114
117
|
|
|
115
118
|
|
|
116
119
|
def _jwt_exp(access_token: str) -> int | None:
|
|
@@ -40,6 +40,8 @@ as you go. If no plan exists in this conversation, say so instead of \
|
|
|
40
40
|
improvising one."""
|
|
41
41
|
|
|
42
42
|
#: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
|
|
43
|
+
#: ``/context`` is not a prompt expansion — serve intercepts it and answers
|
|
44
|
+
#: locally — but it belongs in the same autocomplete list.
|
|
43
45
|
PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
|
|
44
46
|
{
|
|
45
47
|
"name": "/init",
|
|
@@ -49,6 +51,10 @@ PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
|
|
|
49
51
|
"name": "/execute",
|
|
50
52
|
"summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
|
|
51
53
|
},
|
|
54
|
+
{
|
|
55
|
+
"name": "/context",
|
|
56
|
+
"summary": "show what fills the agent's context window (token breakdown)",
|
|
57
|
+
},
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
|