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,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
|
+
]
|