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,193 @@
1
+ """Client-side handler + renderer for the ``edit_content`` tool.
2
+
3
+ Strict find/replace edit:
4
+
5
+ 1. Resolve ``path`` against ``cwd``; refuse missing / non-utf-8 / non-file.
6
+ 2. Validate args: ``old_content`` non-empty, distinct from ``new_content``.
7
+ 3. Count occurrences of ``old_content`` in the file.
8
+
9
+ - 0 → ask the agent to re-read.
10
+ - >1 → ask the agent to add more context.
11
+ - 1 → continue.
12
+
13
+ 4. Build a unified diff preview, ask the user to approve.
14
+ 5. Write the patched content back.
15
+
16
+ Designed to be deterministic: literal string match, no fuzzy logic. If
17
+ the file changed since the last read, the match fails — by design.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import difflib
23
+ from pathlib import Path
24
+ from typing import TYPE_CHECKING, Any
25
+
26
+ from rich.console import Group
27
+ from rich.padding import Padding
28
+ from rich.text import Text
29
+
30
+ if TYPE_CHECKING:
31
+ from rich.console import RenderableType
32
+
33
+ from delos_cli.ctx import Ctx
34
+
35
+
36
+ _DIFF_CONTEXT_LINES = 2
37
+
38
+ #: Sentinels for boundary inserts. When ``old_content`` matches one of
39
+ #: these, the handler skips the literal-match step and prepends /
40
+ #: appends ``new_content`` instead — typical use cases are adding
41
+ #: imports at the top or a new function at the bottom.
42
+ _FIRST_LINE_SENTINEL = "<first_line>"
43
+ _LAST_LINE_SENTINEL = "<last_line>"
44
+
45
+
46
+ async def handle_edit_content(tool_input: dict[str, Any], ctx: Ctx) -> str:
47
+ """Resolve an ``edit_content`` call: validate, match, confirm, write."""
48
+ raw_path = (tool_input.get("path") or "").strip()
49
+ old_content = tool_input.get("old_content") or ""
50
+ new_content = tool_input.get("new_content") or ""
51
+ if not raw_path:
52
+ return "Error: empty path."
53
+ if not isinstance(old_content, str) or not isinstance(new_content, str):
54
+ return "Error: 'old_content' and 'new_content' must both be strings."
55
+ if not old_content:
56
+ return "Error: old_content is empty. Use a non-empty pivot string."
57
+ if old_content == new_content:
58
+ return "Error: old_content and new_content are identical — nothing to do."
59
+
60
+ # ASYNC240 only matters for tight loops; one-shot stat + read + write
61
+ # is fine on the CLI's event loop.
62
+ path = Path(raw_path).expanduser() # noqa: ASYNC240
63
+ if not path.is_absolute():
64
+ path = Path.cwd() / path
65
+
66
+ if not path.exists():
67
+ return f"Error: file does not exist: {path}."
68
+ if not path.is_file():
69
+ return f"Error: not a regular file: {path}."
70
+
71
+ try:
72
+ file_text = path.read_text(encoding="utf-8")
73
+ except UnicodeDecodeError:
74
+ return f"Error: file is not utf-8: {path}."
75
+ except OSError as e:
76
+ return f"Error reading {path}: {e}"
77
+
78
+ # Boundary sentinels skip matching: prepend / append outright. Real
79
+ # text containing the sentinel string verbatim is exceedingly rare
80
+ # (the angle brackets + underscore form is unusual in source code);
81
+ # if we ever hit a false positive, the agent can quote the existing
82
+ # first / last line with extra context to disambiguate.
83
+ if old_content == _FIRST_LINE_SENTINEL:
84
+ new_text = new_content + file_text
85
+ change_summary = f"prepend {len(new_content)} chars"
86
+ elif old_content == _LAST_LINE_SENTINEL:
87
+ new_text = file_text + new_content
88
+ change_summary = f"append {len(new_content)} chars"
89
+ else:
90
+ occurrences = file_text.count(old_content)
91
+ if occurrences == 0:
92
+ return (
93
+ f"Error: old_content not found in {path}. "
94
+ f"Re-read the file with run_shell (`cat {path}`) and try again."
95
+ )
96
+ if occurrences > 1:
97
+ return (
98
+ f"Error: old_content matches {occurrences} locations in {path}. "
99
+ f"Add more context (surrounding lines) to make it unique."
100
+ )
101
+ new_text = file_text.replace(old_content, new_content, 1)
102
+ change_summary = f"{len(old_content)} → {len(new_content)} chars"
103
+
104
+ if ctx.confirm is None:
105
+ sink = ctx.output if ctx.output is not None else ctx.console
106
+ sink.print(
107
+ Text(f"(no confirm hook; aborted: edit_content {path})", style="yellow"),
108
+ )
109
+ return "Tool rejected by the user."
110
+
111
+ # Render the full diff into the scrollable output area (the confirm
112
+ # dialog itself just shows the short ``edit_content → /path`` title
113
+ # so big patches don't overflow it).
114
+ if ctx.output is not None:
115
+ ctx.output.append_block(_diff_panel(path, file_text, new_text))
116
+
117
+ outcome = await ctx.confirm(f"edit_content → {path}")
118
+ if not outcome.accepted:
119
+ if outcome.reason:
120
+ return f"Tool rejected by the user. Reason: {outcome.reason}"
121
+ return "Tool rejected by the user."
122
+
123
+ # TOCTOU guard: the file may have been edited (manually, another
124
+ # process, …) while the dialog was open. Re-read and bail out if
125
+ # what we'd be overwriting isn't what we showed in the preview.
126
+ try:
127
+ current_text = path.read_text(encoding="utf-8")
128
+ except (OSError, UnicodeDecodeError) as e:
129
+ return f"Error re-reading {path}: {e}"
130
+ if current_text != file_text:
131
+ return (
132
+ f"Error: {path} changed on disk between the proposal and your approval. "
133
+ f"Re-read with run_shell (`cat {path}`) and propose the edit again."
134
+ )
135
+
136
+ try:
137
+ path.write_text(new_text, encoding="utf-8")
138
+ except OSError as e:
139
+ return f"Error writing to {path}: {e}"
140
+ return f"Edited {path}: {change_summary}."
141
+
142
+
143
+ def render_edit_content(event: dict[str, Any]) -> RenderableType | None:
144
+ """Show ``✎ edit → /path (N → M chars)`` instead of the default panel."""
145
+ if event.get("type") != "tool-input-available":
146
+ return None
147
+ inputs = event.get("input") or {}
148
+ path = inputs.get("path") or ""
149
+ old = inputs.get("old_content") or ""
150
+ new = inputs.get("new_content") or ""
151
+ if not path:
152
+ return None
153
+ line = Text()
154
+ line.append("✎ ", style="bold magenta")
155
+ line.append("edit → ", style="magenta")
156
+ line.append(str(path), style="bold magenta")
157
+ line.append(f" ({len(old)} → {len(new)} chars)", style="dim")
158
+ return line
159
+
160
+
161
+ def _diff_panel(path: Path, file_text: str, new_text: str) -> Padding:
162
+ """Build a coloured-diff block (no border) for the output area.
163
+
164
+ Lines are coloured per-row (``+`` green, ``-`` red, ``@@`` cyan,
165
+ headers dim) so the user can scan changes at a glance even on long
166
+ patches. Line-level diff via :func:`difflib.unified_diff`
167
+ (``n=2`` context lines).
168
+ """
169
+ lines = list(
170
+ difflib.unified_diff(
171
+ file_text.splitlines(),
172
+ new_text.splitlines(),
173
+ fromfile="before",
174
+ tofile="after",
175
+ n=_DIFF_CONTEXT_LINES,
176
+ lineterm="",
177
+ ),
178
+ )
179
+ body = Text()
180
+ for raw in lines:
181
+ if raw.startswith(("---", "+++")):
182
+ body.append(raw, style="dim")
183
+ elif raw.startswith("@@"):
184
+ body.append(raw, style="cyan")
185
+ elif raw.startswith("+"):
186
+ body.append(raw, style="green")
187
+ elif raw.startswith("-"):
188
+ body.append(raw, style="red")
189
+ else:
190
+ body.append(raw)
191
+ body.append("\n")
192
+ title = Text(f"✎ proposed edit → {path}", style="bold")
193
+ return Padding(Group(title, body), (0, 0, 0, 2))
@@ -0,0 +1,150 @@
1
+ """Client-side handler + renderer for the ``run_shell`` tool.
2
+
3
+ The agent emits ``tool-input-available`` for ``run_shell``; the REPL
4
+ runs :func:`handle_run_shell` which:
5
+
6
+ 1. Decides whether the command can run silently (read-only allowlist
7
+ with no shell metacharacters) or needs a confirmation prompt.
8
+ 2. If a prompt is needed, shows the command in a Rich panel and asks
9
+ the user ``[y/N]`` via prompt_toolkit.
10
+ 3. On approval (or for safe commands), spawns the command with
11
+ :func:`asyncio.create_subprocess_shell`, merges stdout + stderr,
12
+ truncates to a sane size, and returns the result string for the
13
+ next agent turn.
14
+
15
+ A custom renderer prints ``$ <command>`` instead of the default JSON
16
+ panel so the conversation reads naturally.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import os
23
+ from typing import TYPE_CHECKING, Any
24
+
25
+ from rich.text import Text
26
+
27
+ from delos_cli.ctx import ConfirmOutcome
28
+
29
+ if TYPE_CHECKING:
30
+ from rich.console import RenderableType
31
+
32
+ from delos_cli.ctx import Ctx
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Safety policy
37
+ # ---------------------------------------------------------------------------
38
+
39
+ #: Binaries known to be read-only — execute silently when the command
40
+ #: starts with one and has no shell metacharacters. Conservative on
41
+ #: purpose: extra confirmations are mild friction; an unintended write
42
+ #: is a real outage. ``find`` is intentionally absent — it has
43
+ #: ``-delete`` and ``-exec``.
44
+ _SAFE_BINARIES: frozenset[str] = frozenset({"ls", "cat"})
45
+
46
+ #: Tokens that can turn a read into a write (redirection / chaining /
47
+ #: substitution). Their presence anywhere in the command forces a
48
+ #: confirmation, even if the visible binary is in the allowlist.
49
+ _UNSAFE_TOKENS: tuple[str, ...] = (">", "<", "|", "&", ";", "$(", "`")
50
+
51
+ _TIMEOUT_S = 30.0
52
+ _OUTPUT_LIMIT = 8_000
53
+
54
+
55
+ def is_safe_command(command: str) -> bool:
56
+ """True iff ``command`` may run without prompting the user.
57
+
58
+ Allowlist-based: any token that could redirect / chain / substitute
59
+ fails closed (we ask), and only the bare safe binaries pass.
60
+ """
61
+ if any(tok in command for tok in _UNSAFE_TOKENS):
62
+ return False
63
+ parts = command.strip().split(maxsplit=1)
64
+ if not parts:
65
+ return False
66
+ return parts[0] in _SAFE_BINARIES
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Handler — entry point used by AgentSession
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ async def handle_run_shell(tool_input: dict[str, Any], ctx: Ctx) -> str:
75
+ """Resolve a ``run_shell`` tool call and return its output string."""
76
+ command = (tool_input.get("command") or "").strip()
77
+ if not command:
78
+ return "Error: empty command."
79
+
80
+ if not is_safe_command(command):
81
+ outcome = await _ask_user(command, ctx)
82
+ if not outcome.accepted:
83
+ if outcome.reason:
84
+ return f"Tool rejected by the user. Reason: {outcome.reason}"
85
+ return "Tool rejected by the user."
86
+
87
+ return await _run(command)
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Renderer — overrides the default JSON panel with ``$ <command>``
92
+ # ---------------------------------------------------------------------------
93
+
94
+
95
+ def render_run_shell(event: dict[str, Any]) -> RenderableType | None:
96
+ """Render shell tool events as ``$ <command>`` / ``↳ output`` lines."""
97
+ etype = event.get("type")
98
+ if etype == "tool-input-available":
99
+ cmd = (event.get("input") or {}).get("command", "")
100
+ if not cmd:
101
+ return None
102
+ line = Text()
103
+ line.append("$ ", style="bold green")
104
+ line.append(cmd, style="green")
105
+ return line
106
+ return None
107
+
108
+
109
+ # ---------------------------------------------------------------------------
110
+ # Internals
111
+ # ---------------------------------------------------------------------------
112
+
113
+
114
+ async def _ask_user(command: str, ctx: Ctx) -> ConfirmOutcome:
115
+ """Ask the REPL to surface its in-app Accept/Deny dialog.
116
+
117
+ Falls back to a deny outcome when ``ctx.confirm`` isn't installed
118
+ — that should only happen outside the REPL context, where prompting
119
+ is impossible anyway.
120
+ """
121
+ if ctx.confirm is None:
122
+ sink = ctx.output if ctx.output is not None else ctx.console
123
+ sink.print(Text(f"(no confirm hook; denied: {command})", style="yellow"))
124
+ return ConfirmOutcome(accepted=False)
125
+ return await ctx.confirm(command)
126
+
127
+
128
+ async def _run(command: str) -> str:
129
+ """Spawn ``command`` in a subprocess, capture merged output, truncate."""
130
+ proc = await asyncio.create_subprocess_shell(
131
+ command,
132
+ stdout=asyncio.subprocess.PIPE,
133
+ stderr=asyncio.subprocess.STDOUT,
134
+ cwd=os.getcwd(), # noqa: PTH109 — we want literal cwd, not Path-resolved
135
+ )
136
+ try:
137
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_TIMEOUT_S)
138
+ except TimeoutError:
139
+ proc.kill()
140
+ await proc.wait()
141
+ return f"$ {command}\n(timed out after {int(_TIMEOUT_S)}s)"
142
+
143
+ output = stdout.decode("utf-8", errors="replace")
144
+ truncated = ""
145
+ if len(output) > _OUTPUT_LIMIT:
146
+ omitted = len(output) - _OUTPUT_LIMIT
147
+ output = output[:_OUTPUT_LIMIT]
148
+ truncated = f"\n... (truncated, {omitted} chars omitted)"
149
+
150
+ return f"$ {command}\nexit {proc.returncode}\n{output}{truncated}"
@@ -0,0 +1,120 @@
1
+ """Client-side handler + renderer for the ``write_content`` tool.
2
+
3
+ Writes ``content`` to an existing empty file at ``path``:
4
+
5
+ 1. Resolve the path against ``cwd`` (the user's launch folder).
6
+ 2. Refuse if the file is missing, not a regular file, or non-empty —
7
+ ``write_content`` is intentionally narrow so the agent can't ever
8
+ silently clobber existing data. Creation is delegated to
9
+ ``run_shell touch …``; partial edits to ``edit_content``.
10
+ 3. Ask the user to approve the operation (path + content preview).
11
+ 4. Write the file (utf-8) and report back.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from rich.console import Group
20
+ from rich.padding import Padding
21
+ from rich.text import Text
22
+
23
+ if TYPE_CHECKING:
24
+ from rich.console import RenderableType
25
+
26
+ from delos_cli.ctx import Ctx
27
+
28
+
29
+ async def handle_write_content(tool_input: dict[str, Any], ctx: Ctx) -> str:
30
+ """Resolve a ``write_content`` call: validate, confirm, write."""
31
+ raw_path = (tool_input.get("path") or "").strip()
32
+ content = tool_input.get("content") or ""
33
+ if not raw_path:
34
+ return "Error: empty path."
35
+ if not isinstance(content, str):
36
+ return "Error: 'content' must be a string."
37
+
38
+ # ``ASYNC240`` flags pathlib calls inside async functions in case
39
+ # they block the event loop. Tiny stat() / one-shot write here is
40
+ # fine; not worth pulling in ``anyio.path``.
41
+ path = Path(raw_path).expanduser() # noqa: ASYNC240
42
+ if not path.is_absolute():
43
+ path = Path.cwd() / path
44
+
45
+ if not path.exists():
46
+ return (
47
+ f"Error: file does not exist: {path}. "
48
+ f"Use run_shell with `touch` to create it first."
49
+ )
50
+ if not path.is_file():
51
+ return f"Error: not a regular file: {path}."
52
+ if path.stat().st_size > 0:
53
+ return (
54
+ f"Error: file is not empty: {path}. "
55
+ f"write_content only writes to empty files; use edit_content "
56
+ f"to modify an existing file."
57
+ )
58
+
59
+ if ctx.confirm is None:
60
+ sink = ctx.output if ctx.output is not None else ctx.console
61
+ sink.print(
62
+ Text(f"(no confirm hook; aborted: write_content {path})", style="yellow"),
63
+ )
64
+ return "Tool rejected by the user."
65
+
66
+ # Print the proposed content into the scrollable output area so big
67
+ # blocks don't overflow the (small, fixed-height) confirm dialog.
68
+ if ctx.output is not None:
69
+ ctx.output.append_block(_content_panel(path, content))
70
+
71
+ outcome = await ctx.confirm(f"write_content → {path} ({len(content)} chars)")
72
+ if not outcome.accepted:
73
+ if outcome.reason:
74
+ return f"Tool rejected by the user. Reason: {outcome.reason}"
75
+ return "Tool rejected by the user."
76
+
77
+ # TOCTOU guard: the file may have been touched between the empty-
78
+ # check and the user's approval. Bail out if it's no longer empty
79
+ # so we never silently overwrite content the user just added.
80
+ try:
81
+ if path.stat().st_size > 0:
82
+ return (
83
+ f"Error: {path} is no longer empty (was when proposed). "
84
+ f"Re-check and propose again."
85
+ )
86
+ except OSError as e:
87
+ return f"Error re-reading {path}: {e}"
88
+
89
+ try:
90
+ path.write_text(content, encoding="utf-8")
91
+ except OSError as e:
92
+ return f"Error writing to {path}: {e}"
93
+ return f"Wrote {len(content)} chars to {path}."
94
+
95
+
96
+ def render_write_content(event: dict[str, Any]) -> RenderableType | None:
97
+ """Show ``✎ write → path (N chars)`` instead of the default JSON panel."""
98
+ if event.get("type") != "tool-input-available":
99
+ return None
100
+ inputs = event.get("input") or {}
101
+ path = inputs.get("path") or ""
102
+ content = inputs.get("content") or ""
103
+ if not path:
104
+ return None
105
+ line = Text()
106
+ line.append("✎ ", style="bold green")
107
+ line.append("write → ", style="green")
108
+ line.append(str(path), style="bold green")
109
+ line.append(f" ({len(content)} chars)", style="dim")
110
+ return line
111
+
112
+
113
+ def _content_panel(path: Path, content: str) -> Padding:
114
+ """Render the proposed content (no border) for the output area."""
115
+ body = Text(content) if content else Text("(empty)", style="dim")
116
+ title = Text(
117
+ f"✎ proposed write → {path} ({len(content)} chars)",
118
+ style="bold",
119
+ )
120
+ return Padding(Group(title, body), (0, 0, 0, 2))
@@ -0,0 +1,24 @@
1
+ """Transport layer: authed HTTP client + one file per backend / Supabase resource."""
2
+
3
+ from .chats import (
4
+ ChatListItem,
5
+ create_chat,
6
+ delete_chat,
7
+ fetch_messages,
8
+ list_recent,
9
+ rename_chat,
10
+ )
11
+ from .client import AuthedClient, TransportError
12
+ from .models import fetch_models
13
+
14
+ __all__ = [
15
+ "AuthedClient",
16
+ "ChatListItem",
17
+ "TransportError",
18
+ "create_chat",
19
+ "delete_chat",
20
+ "fetch_messages",
21
+ "fetch_models",
22
+ "list_recent",
23
+ "rename_chat",
24
+ ]