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,160 @@
1
+ """Global slash commands: /help, /clear, /rename, /delete, /stop, /resume, /quit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from prompt_toolkit.application import get_app
9
+ from rich.text import Text
10
+
11
+ from delos_cli.transport.chats import create_chat, delete_chat, rename_chat
12
+ from delos_cli.transport.client import TransportError
13
+
14
+ from .base import CommandSpec, Quit, registry_from
15
+
16
+ if TYPE_CHECKING:
17
+ from delos_cli.ctx import Ctx
18
+
19
+
20
+ def _sink(ctx: Ctx) -> Any:
21
+ """Active print target — REPL output buffer if up, else the console."""
22
+ return ctx.output if ctx.output is not None else ctx.console
23
+
24
+
25
+ def _handle_help(ctx: Ctx, args: str) -> None:
26
+ _ = args
27
+ sink = _sink(ctx)
28
+ seen: set[str] = set()
29
+ combined = {**GLOBAL_COMMANDS, **ctx.app.commands}
30
+ for spec in combined.values():
31
+ if spec.name in seen:
32
+ continue
33
+ seen.add(spec.name)
34
+ sink.print(
35
+ Text(spec.display_name, style="bold"),
36
+ Text(f" — {spec.summary}", style="dim"),
37
+ )
38
+
39
+
40
+ async def _handle_clear(ctx: Ctx, args: str) -> None:
41
+ """Wipe the output area + start a fresh conversation (INSERT a new app_cli row)."""
42
+ _ = args
43
+ sink = _sink(ctx)
44
+ # cwd hasn't moved since startup (no shell escape), but read it
45
+ # again here so a future "/cd"-style command stays compatible.
46
+ try:
47
+ new_id = await create_chat(ctx.http, folder=str(Path.cwd()))
48
+ except TransportError as e:
49
+ sink.print(Text(f"clear failed: {e}", style="red"))
50
+ return
51
+ if ctx.output is not None:
52
+ ctx.output.clear()
53
+ ctx.state.conv_id = new_id
54
+ ctx.state.name = None
55
+ ctx.state.turn = 0
56
+ sink.print(Text(f"new conversation {new_id}", style="dim"))
57
+
58
+
59
+ def _handle_quit(ctx: Ctx, args: str) -> None:
60
+ _ = ctx, args
61
+ raise Quit
62
+
63
+
64
+ async def _handle_stop(ctx: Ctx, args: str) -> None:
65
+ _ = args
66
+ sink = _sink(ctx)
67
+ try:
68
+ await ctx.app.cancel(ctx)
69
+ except Exception as e:
70
+ sink.print(Text(f"stop failed: {e}", style="yellow"))
71
+ return
72
+ sink.print(Text("stop signal sent", style="dim"))
73
+
74
+
75
+ def _handle_resume(ctx: Ctx, args: str) -> None:
76
+ """Exit the REPL Application; the outer loop re-shows the chat picker."""
77
+ _ = args
78
+ ctx.state.resume_requested = True
79
+ try:
80
+ get_app().exit()
81
+ except Exception as e: # pragma: no cover — only fails if no app is running
82
+ _sink(ctx).print(Text(f"resume failed: {e}", style="yellow"))
83
+
84
+
85
+ async def _handle_rename(ctx: Ctx, args: str) -> None:
86
+ """``/rename <new name>`` — UPDATE app_cli.name for the active chat."""
87
+ new_name = args.strip()
88
+ sink = _sink(ctx)
89
+ if not new_name:
90
+ sink.print(Text("usage: /rename <new name>", style="yellow"))
91
+ return
92
+ try:
93
+ await rename_chat(ctx.http, ctx.state.conv_id, new_name)
94
+ except TransportError as e:
95
+ sink.print(Text(f"rename failed: {e}", style="red"))
96
+ return
97
+ ctx.state.name = new_name
98
+ sink.print(Text(f"renamed to {new_name!r}", style="dim"))
99
+
100
+
101
+ async def _handle_delete(ctx: Ctx, args: str) -> None:
102
+ """``/delete`` — DELETE the active chat row, then return to the picker."""
103
+ _ = args
104
+ sink = _sink(ctx)
105
+ try:
106
+ await delete_chat(ctx.http, ctx.state.conv_id)
107
+ except TransportError as e:
108
+ sink.print(Text(f"delete failed: {e}", style="red"))
109
+ return
110
+ sink.print(Text("conversation deleted", style="dim"))
111
+ # Bounce back to the picker — same exit path as /resume.
112
+ ctx.state.resume_requested = True
113
+ try:
114
+ get_app().exit()
115
+ except Exception as e: # pragma: no cover
116
+ sink.print(Text(f"(could not exit cleanly: {e})", style="yellow"))
117
+
118
+
119
+ HELP = CommandSpec(
120
+ name="/help",
121
+ summary="show available commands",
122
+ handler=_handle_help,
123
+ )
124
+ CLEAR = CommandSpec(
125
+ name="/clear",
126
+ summary="wipe the screen and start a new conversation",
127
+ handler=_handle_clear,
128
+ )
129
+ STOP = CommandSpec(
130
+ name="/stop",
131
+ summary="signal the backend agent to stop the current run",
132
+ handler=_handle_stop,
133
+ )
134
+ RESUME = CommandSpec(
135
+ name="/resume",
136
+ summary="back to the chat picker — load a different conversation",
137
+ handler=_handle_resume,
138
+ )
139
+ RENAME = CommandSpec(
140
+ name="/rename",
141
+ summary="rename the active conversation",
142
+ handler=_handle_rename,
143
+ args_hint="<new name>",
144
+ )
145
+ DELETE = CommandSpec(
146
+ name="/delete",
147
+ summary="delete the active conversation and return to the picker",
148
+ handler=_handle_delete,
149
+ )
150
+ QUIT = CommandSpec(
151
+ name="/quit",
152
+ summary="exit the REPL",
153
+ handler=_handle_quit,
154
+ aliases=("/exit", ":q"),
155
+ )
156
+
157
+
158
+ GLOBAL_COMMANDS: dict[str, CommandSpec] = registry_from(
159
+ [HELP, CLEAR, STOP, RESUME, RENAME, DELETE, QUIT],
160
+ )
delos_cli/ctx.py ADDED
@@ -0,0 +1,65 @@
1
+ """Runtime context passed to every command and every app.
2
+
3
+ Centralising the handles (config, HTTP client, console, state, current app)
4
+ in one object keeps command signatures small and lets us swap components
5
+ (e.g. the current app) without rewiring every caller.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from collections.abc import Awaitable, Callable
15
+
16
+ from rich.console import Console
17
+
18
+ from .apps.base import App
19
+ from .auth.config import Config
20
+ from .state import ReplState
21
+ from .transport.client import AuthedClient
22
+ from .ui.output import OutputBuffer
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class ConfirmOutcome:
27
+ """Result of an in-app approval prompt.
28
+
29
+ ``accepted`` distinguishes approve from deny; ``reason`` is the
30
+ optional free-form text the user typed when denying — empty when
31
+ the user just pressed Enter on Deny without typing anything.
32
+ """
33
+
34
+ accepted: bool
35
+ reason: str = ""
36
+
37
+
38
+ if TYPE_CHECKING:
39
+ #: Async approval prompt installed by the REPL. Tools call
40
+ #: ``await ctx.confirm("preview text")`` to surface an Accept/Deny
41
+ #: dialog (with optional reason) inside the running prompt_toolkit
42
+ #: Application instead of spawning a nested PromptSession.
43
+ ConfirmFn = Callable[[str], Awaitable[ConfirmOutcome]]
44
+
45
+
46
+ @dataclass
47
+ class Ctx:
48
+ """Everything a command or app handler may need at runtime.
49
+
50
+ ``console`` is for output that happens BEFORE the REPL takes over the
51
+ terminal (login, region picker, fatal errors). Once the REPL is
52
+ running, in-app prints must go through :attr:`output` so they appear
53
+ inside the streaming output area instead of clobbering the layout.
54
+ """
55
+
56
+ cfg: Config
57
+ state: ReplState
58
+ console: Console
59
+ http: AuthedClient
60
+ app: App
61
+ output: OutputBuffer | None = None
62
+ #: Set by the REPL on entry; tools that need user approval should
63
+ #: ``await ctx.confirm("preview text") -> bool`` instead of opening
64
+ #: their own prompt_toolkit session.
65
+ confirm: ConfirmFn | None = None
delos_cli/loop.py ADDED
@@ -0,0 +1,19 @@
1
+ """REPL entrypoint — delegates to the prompt_toolkit Application in :mod:`ui.repl`.
2
+
3
+ Kept as a thin shim so :mod:`delos_cli.main` doesn't need to know about
4
+ the layout details.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import TYPE_CHECKING
10
+
11
+ from .ui.repl import run as _run
12
+
13
+ if TYPE_CHECKING:
14
+ from .ctx import Ctx
15
+
16
+
17
+ async def run(ctx: Ctx) -> None:
18
+ """Drive the REPL until the user quits."""
19
+ await _run(ctx)
delos_cli/main.py ADDED
@@ -0,0 +1,230 @@
1
+ """Typer entrypoint for the `delos` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Annotated
8
+
9
+ import questionary
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+
14
+ from . import __version__
15
+ from .apps import ChatApp
16
+ from .auth import config as cfg_mod
17
+ from .auth.oauth import OAuthError, run_login_flow
18
+ from .ctx import Ctx
19
+ from .loop import run as run_loop
20
+ from .state import ReplState
21
+ from .transport.chats import create_chat, fetch_messages, list_recent
22
+ from .transport.client import AuthedClient, TransportError
23
+ from .ui.chat_picker import pick_conversation
24
+
25
+ app = typer.Typer(
26
+ help="Delos terminal — talk to the Cosmos chat agent from your shell.",
27
+ no_args_is_help=False,
28
+ add_completion=False,
29
+ )
30
+ console = Console()
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def default(ctx: typer.Context) -> None:
35
+ """Start the REPL when `delos` is invoked with no subcommand."""
36
+ if ctx.invoked_subcommand is not None:
37
+ return
38
+ cfg = cfg_mod.load()
39
+ if not cfg.is_signed_in:
40
+ console.print(Text("Not signed in. Run `delos login` first.", style="yellow"))
41
+ raise typer.Exit(code=1)
42
+ asyncio.run(_run_repl(cfg))
43
+
44
+
45
+ async def _run_repl(cfg: cfg_mod.Config) -> None:
46
+ """Show the recent-chats picker, then hand off to the REPL.
47
+
48
+ Loops back to the picker whenever the REPL exits via ``/resume`` so
49
+ the user can hop between conversations without restarting ``delos``.
50
+
51
+ A single :class:`AuthedClient` owns both the backend and Supabase
52
+ REST connections for the whole session.
53
+ """
54
+ # Captured once at REPL start. The user can't ``cd`` from inside
55
+ # delos, so this stays accurate for the whole session — passed to
56
+ # both the picker filter and ``create_chat`` (and ``/clear``).
57
+ folder = str(Path.cwd())
58
+
59
+ async with AuthedClient(cfg) as http:
60
+ # The ChatApp persists across resumes — its tool registry and
61
+ # available_models cache carry over. Picker + REPL are
62
+ # re-entered each iteration so the conv id is fresh per turn.
63
+ chat_app = ChatApp()
64
+
65
+ while True:
66
+ try:
67
+ chats = await list_recent(http, cfg.org_uuid, folder=folder)
68
+ except TransportError as e:
69
+ console.print(Text(f"(could not load recent chats: {e})", style="yellow"))
70
+ chats = []
71
+
72
+ resume_id = await pick_conversation(chats)
73
+
74
+ name: str | None = None
75
+ if resume_id is not None:
76
+ # Stage history so on_enter replays it once the OutputBuffer
77
+ # is wired up; soft-fail (start the chat empty) on transport
78
+ # errors rather than blocking the user from typing.
79
+ try:
80
+ chat_app.pending_replay = await fetch_messages(http, resume_id)
81
+ except TransportError as e:
82
+ console.print(
83
+ Text(f"(could not load chat history: {e})", style="yellow"),
84
+ )
85
+ chat_app.pending_replay = []
86
+ conv_id = resume_id
87
+ # Hydrate the toolbar label from the picker payload — saves
88
+ # an extra fetch. ``next(...)`` returns None when the chat
89
+ # vanished between the list and the pick (unlikely race).
90
+ picked = next((c for c in chats if c.id == resume_id), None)
91
+ name = picked.name if picked else None
92
+ else:
93
+ # User picked "+ New chat" — INSERT the row immediately so the
94
+ # backend has somewhere to append messages on the first turn,
95
+ # and so future model-pick-before-first-message just writes
96
+ # to the existing row. ``folder`` pins it to ``cwd`` so the
97
+ # picker only resurfaces it when the user comes back here.
98
+ try:
99
+ conv_id = await create_chat(http, folder=folder)
100
+ except TransportError as e:
101
+ console.print(Text(f"(could not create chat: {e})", style="red"))
102
+ return
103
+ chat_app.pending_replay = []
104
+
105
+ ctx = Ctx(
106
+ cfg=cfg,
107
+ state=ReplState(conv_id=conv_id, name=name),
108
+ console=console,
109
+ http=http,
110
+ app=chat_app,
111
+ )
112
+ await run_loop(ctx)
113
+ if not ctx.state.resume_requested:
114
+ return
115
+
116
+
117
+ @app.command()
118
+ def login(
119
+ region: Annotated[
120
+ str | None,
121
+ typer.Option(
122
+ "--region",
123
+ help="Region to sign into: eu, us, ae (plus dev/staging when DELOS_INTERNAL=1).",
124
+ ),
125
+ ] = None,
126
+ ) -> None:
127
+ """Open the browser, sign in via OAuth PKCE, save tokens to ~/.config/delos/."""
128
+ cfg = cfg_mod.load()
129
+
130
+ if region:
131
+ allowed = cfg_mod.available_regions()
132
+ if region not in allowed:
133
+ known = ", ".join(sorted(allowed))
134
+ console.print(Text(f"Unknown region '{region}'. Known: {known}", style="red"))
135
+ raise typer.Exit(code=2)
136
+ cfg.region = region
137
+ else:
138
+ picked = _pick_region(cfg.region)
139
+ if picked is None:
140
+ console.print(Text("cancelled.", style="dim"))
141
+ raise typer.Exit(code=1)
142
+ cfg.region = picked
143
+
144
+ console.print(Text(f"Signing in to {cfg.region} ({cfg.web_url})…", style="bold"))
145
+
146
+ async def _prompt(prompt: str) -> str:
147
+ return await asyncio.to_thread(input, prompt)
148
+
149
+ try:
150
+ result = asyncio.run(
151
+ run_login_flow(
152
+ cfg,
153
+ lambda m: console.print(Text(m, style="dim")),
154
+ _prompt,
155
+ ),
156
+ )
157
+ except OAuthError as e:
158
+ console.print(Text(f"Login failed: {e}", style="red"))
159
+ raise typer.Exit(code=1) from e
160
+
161
+ cfg.client_id = result.client_id
162
+ cfg.tokens = result.tokens
163
+ cfg.user_id = result.user_id
164
+ cfg.org_uuid = result.org_uuid
165
+ path = cfg_mod.save(cfg)
166
+
167
+ console.print(Text(f"Signed in as {result.user_id} (org {result.org_uuid})", style="green"))
168
+ console.print(Text(f"saved → {path}", style="dim"))
169
+
170
+
171
+ @app.command()
172
+ def logout() -> None:
173
+ """Forget stored tokens (region is preserved)."""
174
+ cfg = cfg_mod.load()
175
+ cfg.tokens = cfg_mod.Tokens()
176
+ cfg.user_id = ""
177
+ cfg.org_uuid = ""
178
+ cfg_mod.save(cfg)
179
+ console.print(Text("Signed out.", style="green"))
180
+
181
+
182
+ @app.command()
183
+ def whoami() -> None:
184
+ """Print the signed-in user and org, and the effective region URLs."""
185
+ cfg = cfg_mod.load()
186
+ console.print(Text("region ", style="bold"), Text(cfg.region, style="dim"))
187
+ console.print(Text("web ", style="bold"), Text(cfg.web_url, style="dim"))
188
+ console.print(Text("api ", style="bold"), Text(cfg.api_url, style="dim"))
189
+ console.print(Text("user_id ", style="bold"), Text(cfg.user_id or "(not signed in)", style="dim"))
190
+ console.print(Text("org_uuid ", style="bold"), Text(cfg.org_uuid or "(not signed in)", style="dim"))
191
+
192
+
193
+ @app.command()
194
+ def version() -> None:
195
+ """Print the CLI version."""
196
+ console.print(Text(__version__))
197
+
198
+
199
+ _PICKER_STYLE = questionary.Style(
200
+ [
201
+ ("qmark", "fg:ansicyan"),
202
+ ("question", ""),
203
+ ("pointer", "fg:ansicyan"),
204
+ ("highlighted", "fg:ansicyan"),
205
+ ("selected", "fg:ansicyan"),
206
+ ("answer", "fg:ansicyan"),
207
+ ("instruction", "fg:ansigray"),
208
+ ],
209
+ )
210
+
211
+
212
+ def _pick_region(default: str) -> str | None:
213
+ """Inline arrow-key picker (via questionary); returns the chosen region or None if cancelled."""
214
+ _ = default # intentionally unused — always start at the top, no pre-selection highlight
215
+ allowed = cfg_mod.available_regions()
216
+ choices = [
217
+ questionary.Choice(title=f"{name:<8} {info['web']}", value=name)
218
+ for name, info in sorted(allowed.items())
219
+ ]
220
+ return questionary.select(
221
+ "Pick a region:",
222
+ choices=choices,
223
+ use_shortcuts=True,
224
+ qmark="›",
225
+ style=_PICKER_STYLE,
226
+ ).ask()
227
+
228
+
229
+ if __name__ == "__main__":
230
+ app()
delos_cli/state.py ADDED
@@ -0,0 +1,28 @@
1
+ """Cross-app REPL state: conversation id and turn count.
2
+
3
+ App-specific state (model selection, selected tools, …) lives on each
4
+ ``App`` instance rather than here, so one app's state doesn't leak into
5
+ another's toolbar or completer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass
14
+ class ReplState:
15
+ """Mutable state shared between the loop, the toolbar, and the completer."""
16
+
17
+ conv_id: str
18
+ turn: int = 0
19
+ #: Display name for the active conversation. Hydrated from the
20
+ #: picker (``app_cli.name``) on resume; updated by ``/rename``;
21
+ #: stays ``None`` for fresh chats until the auto-name trigger fills
22
+ #: ``app_cli.name`` on the first user message (the toolbar will keep
23
+ #: showing the conv id until the next REPL session reads it back).
24
+ name: str | None = None
25
+ #: Set by ``/resume`` (or ``/delete``) to signal the outer loop to
26
+ #: re-show the chat picker after the Application exits. Cleared on
27
+ #: each new REPL iteration in ``main._run_repl``.
28
+ resume_requested: bool = False
@@ -0,0 +1,20 @@
1
+ """Client-side tool implementations for the Delos CLI.
2
+
3
+ Each module exports a ``handle_<tool>`` (the resolution callback that
4
+ satisfies :class:`~delos_cli.agent.tools.ToolHandler`) and optionally a
5
+ ``render_<tool>`` (a custom :class:`~delos_cli.agent.tools.ToolRenderer`
6
+ to give the tool a nicer visual than the default).
7
+ """
8
+
9
+ from .edit_content import handle_edit_content, render_edit_content
10
+ from .run_shell import handle_run_shell, render_run_shell
11
+ from .write_content import handle_write_content, render_write_content
12
+
13
+ __all__ = [
14
+ "handle_edit_content",
15
+ "handle_run_shell",
16
+ "handle_write_content",
17
+ "render_edit_content",
18
+ "render_run_shell",
19
+ "render_write_content",
20
+ ]