dscli-agent 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.
- dscli/__init__.py +3 -0
- dscli/__main__.py +4 -0
- dscli/agent.py +312 -0
- dscli/attachments.py +122 -0
- dscli/browser.py +1354 -0
- dscli/cli.py +842 -0
- dscli/config.py +191 -0
- dscli/display.py +108 -0
- dscli/prompts.py +263 -0
- dscli/protocol.py +633 -0
- dscli/scraper.py +268 -0
- dscli/tools.py +674 -0
- dscli/tui.py +574 -0
- dscli/ui.py +72 -0
- dscli_agent-0.1.0.dist-info/METADATA +379 -0
- dscli_agent-0.1.0.dist-info/RECORD +20 -0
- dscli_agent-0.1.0.dist-info/WHEEL +5 -0
- dscli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- dscli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- dscli_agent-0.1.0.dist-info/top_level.txt +1 -0
dscli/tools.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import difflib
|
|
4
|
+
import locale
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from .protocol import CANONICAL_TOOLS, ToolCall, canonical_tool_name
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ToolError(ValueError):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PathDenied(ToolError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ApprovalRequired(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ToolDenied(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ConfirmFn = Callable[[str, str | None], bool]
|
|
33
|
+
|
|
34
|
+
SKIP_DIRS = {
|
|
35
|
+
".git",
|
|
36
|
+
".hg",
|
|
37
|
+
".svn",
|
|
38
|
+
"node_modules",
|
|
39
|
+
"__pycache__",
|
|
40
|
+
".venv",
|
|
41
|
+
"venv",
|
|
42
|
+
"env",
|
|
43
|
+
".mypy_cache",
|
|
44
|
+
".pytest_cache",
|
|
45
|
+
".ruff_cache",
|
|
46
|
+
".next",
|
|
47
|
+
".nuxt",
|
|
48
|
+
".turbo",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
"target",
|
|
52
|
+
"coverage",
|
|
53
|
+
".idea",
|
|
54
|
+
".tox",
|
|
55
|
+
}
|
|
56
|
+
TEXT_SKIP_EXTS = {
|
|
57
|
+
".pyc",
|
|
58
|
+
".pyo",
|
|
59
|
+
".so",
|
|
60
|
+
".dll",
|
|
61
|
+
".exe",
|
|
62
|
+
".bin",
|
|
63
|
+
".zip",
|
|
64
|
+
".gz",
|
|
65
|
+
".7z",
|
|
66
|
+
".tar",
|
|
67
|
+
".png",
|
|
68
|
+
".jpg",
|
|
69
|
+
".jpeg",
|
|
70
|
+
".gif",
|
|
71
|
+
".webp",
|
|
72
|
+
".ico",
|
|
73
|
+
".pdf",
|
|
74
|
+
".mp4",
|
|
75
|
+
".mov",
|
|
76
|
+
".mp3",
|
|
77
|
+
".wav",
|
|
78
|
+
".woff",
|
|
79
|
+
".woff2",
|
|
80
|
+
".ttf",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ToolResult:
|
|
86
|
+
name: str
|
|
87
|
+
arguments: dict[str, Any]
|
|
88
|
+
ok: bool
|
|
89
|
+
output: str
|
|
90
|
+
changed: list[str] = field(default_factory=list)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class _TextFile:
|
|
95
|
+
text: str
|
|
96
|
+
encoding: str
|
|
97
|
+
newline: str
|
|
98
|
+
had_bom: bool
|
|
99
|
+
size: int
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _human_size(size: int) -> str:
|
|
103
|
+
value = float(size)
|
|
104
|
+
for unit in ("B", "KB", "MB"):
|
|
105
|
+
if value < 1024 or unit == "MB":
|
|
106
|
+
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
|
107
|
+
value /= 1024
|
|
108
|
+
return f"{value:.1f} MB"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _truncate(text: str, limit: int, note: str = "output truncated") -> str:
|
|
112
|
+
if len(text) <= limit:
|
|
113
|
+
return text
|
|
114
|
+
keep = max(0, limit - 80)
|
|
115
|
+
return text[:keep] + f"\n... [{note}: {len(text) - keep} more characters] ..."
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _truncate_middle(text: str, limit: int) -> str:
|
|
119
|
+
if len(text) <= limit:
|
|
120
|
+
return text
|
|
121
|
+
half = max(0, (limit - 80) // 2)
|
|
122
|
+
return (
|
|
123
|
+
text[:half]
|
|
124
|
+
+ f"\n... [{len(text) - 2 * half} characters omitted] ...\n"
|
|
125
|
+
+ text[-half:]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _decode(raw: bytes) -> str:
|
|
130
|
+
for enc in ("utf-8-sig", "utf-8", locale.getpreferredencoding(False), "cp1252"):
|
|
131
|
+
if not enc:
|
|
132
|
+
continue
|
|
133
|
+
try:
|
|
134
|
+
return raw.decode(enc)
|
|
135
|
+
except (UnicodeDecodeError, LookupError):
|
|
136
|
+
continue
|
|
137
|
+
return raw.decode("utf-8", errors="replace")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _rel(path: Path, root: Path) -> str:
|
|
141
|
+
try:
|
|
142
|
+
return path.relative_to(root).as_posix()
|
|
143
|
+
except ValueError:
|
|
144
|
+
return str(path)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def workspace_preview(root: Path, max_entries: int = 40) -> str:
|
|
148
|
+
try:
|
|
149
|
+
entries = sorted(root.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
150
|
+
except OSError as exc:
|
|
151
|
+
return f"(cannot list workspace: {exc})"
|
|
152
|
+
lines: list[str] = []
|
|
153
|
+
for entry in entries:
|
|
154
|
+
if entry.name in SKIP_DIRS:
|
|
155
|
+
continue
|
|
156
|
+
if entry.is_dir():
|
|
157
|
+
lines.append(f"{entry.name}/")
|
|
158
|
+
else:
|
|
159
|
+
try:
|
|
160
|
+
size = _human_size(entry.stat().st_size)
|
|
161
|
+
except OSError:
|
|
162
|
+
size = "?"
|
|
163
|
+
lines.append(f"{entry.name} ({size})")
|
|
164
|
+
if len(lines) >= max_entries:
|
|
165
|
+
lines.append("... (more entries; use list_files)")
|
|
166
|
+
break
|
|
167
|
+
return "\n".join(lines) or "(empty workspace)"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class Toolbox:
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
root: Path,
|
|
174
|
+
*,
|
|
175
|
+
allow_outside: bool = False,
|
|
176
|
+
allow_shell: bool = False,
|
|
177
|
+
confirm: ConfirmFn | None = None,
|
|
178
|
+
max_read_chars: int = 40000,
|
|
179
|
+
max_write_chars: int = 1_000_000,
|
|
180
|
+
max_output_chars: int = 20000,
|
|
181
|
+
shell_timeout_sec: int = 120,
|
|
182
|
+
) -> None:
|
|
183
|
+
self.root = root.resolve()
|
|
184
|
+
self.allow_outside = allow_outside
|
|
185
|
+
self.allow_shell = allow_shell
|
|
186
|
+
self.confirm = confirm
|
|
187
|
+
self.max_read_chars = max_read_chars
|
|
188
|
+
self.max_write_chars = max_write_chars
|
|
189
|
+
self.max_output_chars = max_output_chars
|
|
190
|
+
self.shell_timeout_sec = shell_timeout_sec
|
|
191
|
+
self._read_any: set[str] = set()
|
|
192
|
+
self._changed: list[str] = []
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------ paths
|
|
195
|
+
def _resolve(self, value: Any, *, must_exist: bool = False, for_write: bool = False) -> Path:
|
|
196
|
+
if value is None or str(value).strip() == "":
|
|
197
|
+
value = "."
|
|
198
|
+
if not isinstance(value, (str, os.PathLike)):
|
|
199
|
+
raise ToolError(f"path must be a string, got {type(value).__name__}")
|
|
200
|
+
path = Path(str(value)).expanduser()
|
|
201
|
+
if not path.is_absolute():
|
|
202
|
+
path = self.root / path
|
|
203
|
+
resolved = path.resolve()
|
|
204
|
+
try:
|
|
205
|
+
inside = resolved.is_relative_to(self.root)
|
|
206
|
+
except ValueError:
|
|
207
|
+
inside = False
|
|
208
|
+
if not inside and not self.allow_outside:
|
|
209
|
+
raise PathDenied(
|
|
210
|
+
f"Path '{value}' is outside the workspace ({self.root}). "
|
|
211
|
+
"Pass --allow-outside to permit this."
|
|
212
|
+
)
|
|
213
|
+
if for_write and inside:
|
|
214
|
+
rel_parts = resolved.relative_to(self.root).parts
|
|
215
|
+
if ".git" in rel_parts:
|
|
216
|
+
raise PathDenied("Refusing to modify anything inside .git.")
|
|
217
|
+
if must_exist and not resolved.exists():
|
|
218
|
+
raise ToolError(f"Path does not exist: {value}")
|
|
219
|
+
return resolved
|
|
220
|
+
|
|
221
|
+
def _display(self, path: Path) -> str:
|
|
222
|
+
return _rel(path, self.root)
|
|
223
|
+
|
|
224
|
+
def _read_text(self, path: Path) -> _TextFile:
|
|
225
|
+
if not path.exists():
|
|
226
|
+
raise ToolError(f"File does not exist: {self._display(path)}")
|
|
227
|
+
if path.is_dir():
|
|
228
|
+
raise ToolError(f"{self._display(path)} is a directory; use list_files.")
|
|
229
|
+
raw = path.read_bytes()
|
|
230
|
+
if b"\x00" in raw[:8192]:
|
|
231
|
+
raise ToolError(
|
|
232
|
+
f"{self._display(path)} looks like a binary file and cannot be edited as text."
|
|
233
|
+
)
|
|
234
|
+
had_bom = raw.startswith(b"\xef\xbb\xbf")
|
|
235
|
+
text: str | None = None
|
|
236
|
+
encoding = "utf-8-sig" if had_bom else "utf-8"
|
|
237
|
+
try:
|
|
238
|
+
text = raw.decode(encoding)
|
|
239
|
+
except UnicodeDecodeError:
|
|
240
|
+
for fallback in ("cp1252", locale.getpreferredencoding(False) or "cp1252"):
|
|
241
|
+
try:
|
|
242
|
+
text = raw.decode(fallback)
|
|
243
|
+
encoding = fallback
|
|
244
|
+
break
|
|
245
|
+
except (UnicodeDecodeError, LookupError):
|
|
246
|
+
continue
|
|
247
|
+
if text is None:
|
|
248
|
+
raise ToolError(f"Cannot decode {self._display(path)} as text.")
|
|
249
|
+
crlf = text.count("\r\n")
|
|
250
|
+
lf = text.count("\n") - crlf
|
|
251
|
+
newline = "\r\n" if crlf > lf else "\n"
|
|
252
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
253
|
+
return _TextFile(
|
|
254
|
+
text=normalized,
|
|
255
|
+
encoding=encoding,
|
|
256
|
+
newline=newline,
|
|
257
|
+
had_bom=had_bom,
|
|
258
|
+
size=len(raw),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _write_text(self, path: Path, text: str, *, encoding: str = "utf-8", newline: str = "\n") -> None:
|
|
262
|
+
data = text
|
|
263
|
+
if newline != "\n":
|
|
264
|
+
data = data.replace("\n", newline)
|
|
265
|
+
try:
|
|
266
|
+
encoded = data.encode(encoding)
|
|
267
|
+
except UnicodeEncodeError as exc:
|
|
268
|
+
raise ToolError(
|
|
269
|
+
f"Cannot encode the new content as {encoding}; the file may use a legacy codepage."
|
|
270
|
+
) from exc
|
|
271
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
mode = None
|
|
273
|
+
if path.exists():
|
|
274
|
+
try:
|
|
275
|
+
mode = path.stat().st_mode
|
|
276
|
+
except OSError:
|
|
277
|
+
pass
|
|
278
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".dscli-", suffix=".tmp", dir=str(path.parent))
|
|
279
|
+
try:
|
|
280
|
+
with os.fdopen(fd, "wb") as fh:
|
|
281
|
+
fh.write(encoded)
|
|
282
|
+
if mode is not None:
|
|
283
|
+
try:
|
|
284
|
+
os.chmod(tmp_name, mode)
|
|
285
|
+
except OSError:
|
|
286
|
+
pass
|
|
287
|
+
os.replace(tmp_name, path)
|
|
288
|
+
finally:
|
|
289
|
+
try:
|
|
290
|
+
if os.path.exists(tmp_name):
|
|
291
|
+
os.unlink(tmp_name)
|
|
292
|
+
except OSError:
|
|
293
|
+
pass
|
|
294
|
+
|
|
295
|
+
def _approve(self, title: str, detail: str | None = None) -> None:
|
|
296
|
+
if self.confirm is None:
|
|
297
|
+
raise ApprovalRequired(f"{title} needs confirmation, but no confirmer is configured.")
|
|
298
|
+
if not self.confirm(title, detail):
|
|
299
|
+
raise ToolDenied("The user denied this action.")
|
|
300
|
+
|
|
301
|
+
def _diff(self, old: str, new: str, path: Path) -> str:
|
|
302
|
+
rel = self._display(path)
|
|
303
|
+
diff = difflib.unified_diff(
|
|
304
|
+
old.splitlines(keepends=True),
|
|
305
|
+
new.splitlines(keepends=True),
|
|
306
|
+
fromfile=f"a/{rel}",
|
|
307
|
+
tofile=f"b/{rel}",
|
|
308
|
+
n=3,
|
|
309
|
+
)
|
|
310
|
+
return "".join(diff) or "(no changes)"
|
|
311
|
+
|
|
312
|
+
def _mark_written(self, path: Path) -> None:
|
|
313
|
+
key = str(path)
|
|
314
|
+
self._read_any.add(key)
|
|
315
|
+
rel = self._display(path)
|
|
316
|
+
if rel not in self._changed:
|
|
317
|
+
self._changed.append(rel)
|
|
318
|
+
|
|
319
|
+
# ------------------------------------------------------------------ tools
|
|
320
|
+
def execute(self, call: ToolCall) -> ToolResult:
|
|
321
|
+
name = canonical_tool_name(call.name)
|
|
322
|
+
args = self._normalize_args(name, call.arguments or {})
|
|
323
|
+
try:
|
|
324
|
+
if name == "read_file":
|
|
325
|
+
result = self._read_file(args)
|
|
326
|
+
elif name == "write_file":
|
|
327
|
+
result = self._write_file(args)
|
|
328
|
+
elif name == "apply_patch":
|
|
329
|
+
result = self._apply_patch(args)
|
|
330
|
+
elif name == "list_files":
|
|
331
|
+
result = self._list_files(args)
|
|
332
|
+
elif name == "delete_file":
|
|
333
|
+
result = self._delete_file(args)
|
|
334
|
+
elif name == "run_command":
|
|
335
|
+
result = self._run_command(args)
|
|
336
|
+
elif name == "finish":
|
|
337
|
+
raise ToolError("finish is handled by the harness; use the final field instead.")
|
|
338
|
+
else:
|
|
339
|
+
raise ToolError(
|
|
340
|
+
"Unknown tool. Valid tools: " + ", ".join(sorted(CANONICAL_TOOLS)) + "."
|
|
341
|
+
)
|
|
342
|
+
result.name = name
|
|
343
|
+
result.arguments = args
|
|
344
|
+
if result.changed:
|
|
345
|
+
for rel in result.changed:
|
|
346
|
+
if rel not in self._changed:
|
|
347
|
+
self._changed.append(rel)
|
|
348
|
+
return result
|
|
349
|
+
except ToolDenied as exc:
|
|
350
|
+
return ToolResult(name=name, arguments=args, ok=False, output=str(exc))
|
|
351
|
+
except ApprovalRequired:
|
|
352
|
+
raise
|
|
353
|
+
except (ToolError, OSError) as exc:
|
|
354
|
+
return ToolResult(name=name, arguments=args, ok=False, output=str(exc))
|
|
355
|
+
except Exception as exc: # keep the agent alive on unexpected tool errors
|
|
356
|
+
return ToolResult(
|
|
357
|
+
name=name,
|
|
358
|
+
arguments=args,
|
|
359
|
+
ok=False,
|
|
360
|
+
output=f"Internal tool error: {type(exc).__name__}: {exc}",
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _normalize_args(self, name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|
364
|
+
args = dict(args)
|
|
365
|
+
|
|
366
|
+
def pick(*keys: str) -> Any:
|
|
367
|
+
for key in keys:
|
|
368
|
+
if key in args and args[key] is not None:
|
|
369
|
+
return args[key]
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
path = pick("path", "file_path", "filename", "file", "dir", "directory")
|
|
373
|
+
if path is None and name in {"read_file", "write_file", "apply_patch", "delete_file"} and "input" in args:
|
|
374
|
+
path = args["input"]
|
|
375
|
+
if path is not None:
|
|
376
|
+
args["path"] = path
|
|
377
|
+
|
|
378
|
+
if name == "write_file":
|
|
379
|
+
content = pick("content", "contents", "text", "body", "new_content", "data")
|
|
380
|
+
if content is not None:
|
|
381
|
+
args["content"] = content
|
|
382
|
+
if name == "apply_patch":
|
|
383
|
+
search = pick("search", "old_string", "old", "find", "target", "pattern")
|
|
384
|
+
replace = pick("replace", "new_string", "new", "replacement", "replace_with")
|
|
385
|
+
if search is not None:
|
|
386
|
+
args["search"] = search
|
|
387
|
+
if replace is not None:
|
|
388
|
+
args["replace"] = replace
|
|
389
|
+
all_flag = pick("replace_all", "all", "global")
|
|
390
|
+
if all_flag is not None:
|
|
391
|
+
if isinstance(all_flag, str):
|
|
392
|
+
all_flag = all_flag.strip().lower() in {"1", "true", "yes", "all"}
|
|
393
|
+
args["replace_all"] = bool(all_flag)
|
|
394
|
+
if name == "run_command":
|
|
395
|
+
command = pick("command", "cmd", "script", "input")
|
|
396
|
+
if command is not None:
|
|
397
|
+
args["command"] = command
|
|
398
|
+
timeout = pick("timeout_sec", "timeout", "timeout_seconds")
|
|
399
|
+
if timeout is not None:
|
|
400
|
+
args["timeout_sec"] = timeout
|
|
401
|
+
if name == "list_files" and args.get("path") is None:
|
|
402
|
+
args["path"] = "."
|
|
403
|
+
return args
|
|
404
|
+
|
|
405
|
+
def _read_file(self, args: dict[str, Any]) -> ToolResult:
|
|
406
|
+
path = self._resolve(args.get("path"), must_exist=True)
|
|
407
|
+
tf = self._read_text(path)
|
|
408
|
+
lines = tf.text.splitlines()
|
|
409
|
+
total_lines = len(lines)
|
|
410
|
+
try:
|
|
411
|
+
start = int(args.get("start_line") or 1)
|
|
412
|
+
end = int(args.get("end_line") or 0)
|
|
413
|
+
except (TypeError, ValueError):
|
|
414
|
+
raise ToolError("start_line and end_line must be integers.")
|
|
415
|
+
if start < 1:
|
|
416
|
+
start = 1
|
|
417
|
+
if end and end < start:
|
|
418
|
+
raise ToolError("end_line must be greater than or equal to start_line.")
|
|
419
|
+
if start > total_lines and total_lines > 0:
|
|
420
|
+
raise ToolError(f"start_line {start} is past the end ({total_lines} lines).")
|
|
421
|
+
end = end or total_lines
|
|
422
|
+
end = min(end, total_lines)
|
|
423
|
+
selected = "\n".join(lines[start - 1 : end])
|
|
424
|
+
truncated = False
|
|
425
|
+
if len(selected) > self.max_read_chars:
|
|
426
|
+
selected = selected[: self.max_read_chars]
|
|
427
|
+
truncated = True
|
|
428
|
+
if start == 1 and end >= total_lines and not truncated:
|
|
429
|
+
pass
|
|
430
|
+
self._read_any.add(str(path))
|
|
431
|
+
header = (
|
|
432
|
+
f"{self._display(path)} | lines {start}-{end} of {total_lines} | "
|
|
433
|
+
f"{tf.size} bytes | encoding {tf.encoding}"
|
|
434
|
+
)
|
|
435
|
+
if truncated:
|
|
436
|
+
header += f" | TRUNCATED at {self.max_read_chars} chars; use start_line/end_line"
|
|
437
|
+
output = header + "\n" + selected
|
|
438
|
+
if not selected and total_lines == 0:
|
|
439
|
+
output = header + "\n(empty file)"
|
|
440
|
+
return ToolResult(name="read_file", arguments=args, ok=True, output=output)
|
|
441
|
+
|
|
442
|
+
def _write_file(self, args: dict[str, Any]) -> ToolResult:
|
|
443
|
+
path = self._resolve(args.get("path"), for_write=True)
|
|
444
|
+
content = args.get("content")
|
|
445
|
+
if not isinstance(content, str):
|
|
446
|
+
raise ToolError("write_file requires a string 'content'.")
|
|
447
|
+
if len(content) > self.max_write_chars:
|
|
448
|
+
raise ToolError(
|
|
449
|
+
f"Content is too large ({len(content)} chars; limit {self.max_write_chars})."
|
|
450
|
+
)
|
|
451
|
+
key = str(path)
|
|
452
|
+
existed = path.exists()
|
|
453
|
+
if existed and path.is_dir():
|
|
454
|
+
raise ToolError(f"{self._display(path)} is a directory.")
|
|
455
|
+
if existed and path.stat().st_size > 0:
|
|
456
|
+
raise ToolError(
|
|
457
|
+
f"{self._display(path)} already exists. write_file only creates new files; "
|
|
458
|
+
"use apply_patch to modify an existing file."
|
|
459
|
+
)
|
|
460
|
+
new_norm = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
461
|
+
if existed:
|
|
462
|
+
# Existing empty file: replacing it is safe and unambiguous.
|
|
463
|
+
diff = self._diff("", new_norm, path)
|
|
464
|
+
self._approve(f"Fill empty file {self._display(path)}?", diff)
|
|
465
|
+
else:
|
|
466
|
+
diff = self._diff("", new_norm, path)
|
|
467
|
+
self._approve(f"Create {self._display(path)}?", diff)
|
|
468
|
+
self._write_text(path, new_norm, encoding="utf-8", newline="\n")
|
|
469
|
+
self._mark_written(path)
|
|
470
|
+
return ToolResult(
|
|
471
|
+
name="write_file",
|
|
472
|
+
arguments=args,
|
|
473
|
+
ok=True,
|
|
474
|
+
output=f"Wrote {self._display(path)} ({len(new_norm)} chars, {new_norm.count(chr(10)) + 1} lines).",
|
|
475
|
+
changed=[self._display(path)],
|
|
476
|
+
)
|
|
477
|
+
|
|
478
|
+
def _apply_patch(self, args: dict[str, Any]) -> ToolResult:
|
|
479
|
+
path = self._resolve(args.get("path"), must_exist=True, for_write=True)
|
|
480
|
+
if not path.is_file():
|
|
481
|
+
raise ToolError(f"{self._display(path)} is not a file.")
|
|
482
|
+
search = args.get("search")
|
|
483
|
+
replace = args.get("replace")
|
|
484
|
+
if not isinstance(search, str) or not search:
|
|
485
|
+
raise ToolError("apply_patch requires a non-empty string 'search'.")
|
|
486
|
+
if not isinstance(replace, str):
|
|
487
|
+
raise ToolError("apply_patch requires a string 'replace'.")
|
|
488
|
+
key = str(path)
|
|
489
|
+
if key not in self._read_any:
|
|
490
|
+
raise ToolError(
|
|
491
|
+
f"Read {self._display(path)} with read_file before patching it."
|
|
492
|
+
)
|
|
493
|
+
tf = self._read_text(path)
|
|
494
|
+
search_norm = search.replace("\r\n", "\n").replace("\r", "\n")
|
|
495
|
+
replace_norm = replace.replace("\r\n", "\n").replace("\r", "\n")
|
|
496
|
+
count = tf.text.count(search_norm)
|
|
497
|
+
if count == 0:
|
|
498
|
+
raise ToolError(
|
|
499
|
+
"The search text was not found. Re-read the file and copy the exact "
|
|
500
|
+
"text, including indentation."
|
|
501
|
+
)
|
|
502
|
+
replace_all = bool(args.get("replace_all"))
|
|
503
|
+
if count > 1 and not replace_all:
|
|
504
|
+
raise ToolError(
|
|
505
|
+
f"The search text occurs {count} times. Add more surrounding context "
|
|
506
|
+
"or pass replace_all=true."
|
|
507
|
+
)
|
|
508
|
+
new_text = tf.text.replace(search_norm, replace_norm, -1 if replace_all else 1)
|
|
509
|
+
if new_text == tf.text:
|
|
510
|
+
return ToolResult(
|
|
511
|
+
name="apply_patch", arguments=args, ok=True,
|
|
512
|
+
output=f"No changes produced for {self._display(path)}.",
|
|
513
|
+
)
|
|
514
|
+
if len(new_text) > self.max_write_chars:
|
|
515
|
+
raise ToolError(
|
|
516
|
+
f"Patched file would be too large ({len(new_text)} chars; limit {self.max_write_chars})."
|
|
517
|
+
)
|
|
518
|
+
diff = self._diff(tf.text, new_text, path)
|
|
519
|
+
self._approve(f"Apply patch to {self._display(path)}?", diff)
|
|
520
|
+
self._write_text(path, new_text, encoding=tf.encoding, newline=tf.newline)
|
|
521
|
+
self._mark_written(path)
|
|
522
|
+
return ToolResult(
|
|
523
|
+
name="apply_patch",
|
|
524
|
+
arguments=args,
|
|
525
|
+
ok=True,
|
|
526
|
+
output=f"Patched {self._display(path)} ({count} match(es); +{len(new_text) - len(tf.text)} chars).",
|
|
527
|
+
changed=[self._display(path)],
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
def _list_files(self, args: dict[str, Any]) -> ToolResult:
|
|
531
|
+
path = self._resolve(args.get("path") or ".")
|
|
532
|
+
if not path.exists():
|
|
533
|
+
raise ToolError(f"Path does not exist: {self._display(path)}")
|
|
534
|
+
if not path.is_dir():
|
|
535
|
+
raise ToolError(f"{self._display(path)} is not a directory.")
|
|
536
|
+
try:
|
|
537
|
+
depth = int(args.get("depth") or 2)
|
|
538
|
+
except (TypeError, ValueError):
|
|
539
|
+
depth = 2
|
|
540
|
+
depth = max(1, min(depth, 6))
|
|
541
|
+
max_entries = 300
|
|
542
|
+
lines: list[str] = []
|
|
543
|
+
truncated = False
|
|
544
|
+
for current, dirs, files in os.walk(path):
|
|
545
|
+
current_path = Path(current)
|
|
546
|
+
rel = current_path.relative_to(path)
|
|
547
|
+
level = 0 if str(rel) == "." else len(rel.parts)
|
|
548
|
+
if level >= depth:
|
|
549
|
+
dirs[:] = []
|
|
550
|
+
continue
|
|
551
|
+
dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
|
|
552
|
+
for d in dirs:
|
|
553
|
+
if len(lines) >= max_entries:
|
|
554
|
+
truncated = True
|
|
555
|
+
break
|
|
556
|
+
child = current_path / d
|
|
557
|
+
if child.is_symlink():
|
|
558
|
+
continue
|
|
559
|
+
lines.append(f"{self._display(child)}/")
|
|
560
|
+
for f in sorted(files):
|
|
561
|
+
if len(lines) >= max_entries:
|
|
562
|
+
truncated = True
|
|
563
|
+
break
|
|
564
|
+
child = current_path / f
|
|
565
|
+
try:
|
|
566
|
+
size = child.stat().st_size
|
|
567
|
+
except OSError:
|
|
568
|
+
size = -1
|
|
569
|
+
if child.suffix.lower() in TEXT_SKIP_EXTS and size > 2_000_000:
|
|
570
|
+
continue
|
|
571
|
+
lines.append(f"{self._display(child)} ({_human_size(size) if size >= 0 else '?'})")
|
|
572
|
+
if truncated:
|
|
573
|
+
break
|
|
574
|
+
output = "\n".join(lines) if lines else "(directory is empty)"
|
|
575
|
+
if truncated:
|
|
576
|
+
output += f"\n... [listing truncated at {max_entries} entries; narrow the path or depth]"
|
|
577
|
+
return ToolResult(name="list_files", arguments=args, ok=True, output=output)
|
|
578
|
+
|
|
579
|
+
def _delete_file(self, args: dict[str, Any]) -> ToolResult:
|
|
580
|
+
path = self._resolve(args.get("path"), must_exist=True, for_write=True)
|
|
581
|
+
if path.is_dir():
|
|
582
|
+
raise ToolError("delete_file only deletes single files, not directories.")
|
|
583
|
+
if not path.is_file():
|
|
584
|
+
raise ToolError(f"Not a regular file: {self._display(path)}")
|
|
585
|
+
try:
|
|
586
|
+
size = path.stat().st_size
|
|
587
|
+
except OSError:
|
|
588
|
+
size = 0
|
|
589
|
+
self._approve(f"Delete {self._display(path)} ({_human_size(size)})?", None)
|
|
590
|
+
path.unlink()
|
|
591
|
+
key = str(path)
|
|
592
|
+
self._read_any.discard(key)
|
|
593
|
+
self._changed.append(self._display(path))
|
|
594
|
+
return ToolResult(
|
|
595
|
+
name="delete_file",
|
|
596
|
+
arguments=args,
|
|
597
|
+
ok=True,
|
|
598
|
+
output=f"Deleted {self._display(path)}.",
|
|
599
|
+
changed=[self._display(path)],
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
def _run_command(self, args: dict[str, Any]) -> ToolResult:
|
|
603
|
+
if not self.allow_shell:
|
|
604
|
+
raise ToolError(
|
|
605
|
+
"Shell access is disabled for this run. Ask the user to rerun with --allow-shell "
|
|
606
|
+
"and approve the command."
|
|
607
|
+
)
|
|
608
|
+
command = args.get("command")
|
|
609
|
+
if not isinstance(command, str) or not command.strip():
|
|
610
|
+
raise ToolError("run_command requires a non-empty string 'command'.")
|
|
611
|
+
if len(command) > 8000:
|
|
612
|
+
raise ToolError("Command is too long.")
|
|
613
|
+
try:
|
|
614
|
+
timeout = int(args.get("timeout_sec") or self.shell_timeout_sec)
|
|
615
|
+
except (TypeError, ValueError):
|
|
616
|
+
timeout = self.shell_timeout_sec
|
|
617
|
+
timeout = max(1, min(timeout, 600))
|
|
618
|
+
self._approve(f"Run command in {self.root}?", command)
|
|
619
|
+
if os.name == "nt":
|
|
620
|
+
pwsh = shutil.which("pwsh") or shutil.which("powershell")
|
|
621
|
+
if pwsh:
|
|
622
|
+
cmd = [
|
|
623
|
+
pwsh,
|
|
624
|
+
"-NoLogo",
|
|
625
|
+
"-NoProfile",
|
|
626
|
+
"-NonInteractive",
|
|
627
|
+
"-Command",
|
|
628
|
+
"$ProgressPreference='SilentlyContinue'; "
|
|
629
|
+
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; " + command,
|
|
630
|
+
]
|
|
631
|
+
else:
|
|
632
|
+
cmd = [os.environ.get("COMSPEC", "cmd.exe"), "/d", "/s", "/c", command]
|
|
633
|
+
else:
|
|
634
|
+
cmd = ["/bin/sh", "-c", command]
|
|
635
|
+
try:
|
|
636
|
+
proc = subprocess.run(
|
|
637
|
+
cmd,
|
|
638
|
+
cwd=str(self.root),
|
|
639
|
+
stdout=subprocess.PIPE,
|
|
640
|
+
stderr=subprocess.PIPE,
|
|
641
|
+
timeout=timeout,
|
|
642
|
+
)
|
|
643
|
+
except subprocess.TimeoutExpired as exc:
|
|
644
|
+
stdout = _decode(exc.stdout or b"")
|
|
645
|
+
stderr = _decode(exc.stderr or b"")
|
|
646
|
+
body = (stdout + stderr).strip()
|
|
647
|
+
return ToolResult(
|
|
648
|
+
name="run_command",
|
|
649
|
+
arguments=args,
|
|
650
|
+
ok=False,
|
|
651
|
+
output=f"Command timed out after {timeout}s." + (f"\n{body}" if body else ""),
|
|
652
|
+
)
|
|
653
|
+
stdout = _decode(proc.stdout or b"")
|
|
654
|
+
stderr = _decode(proc.stderr or b"")
|
|
655
|
+
combined = stdout
|
|
656
|
+
if stderr:
|
|
657
|
+
combined += ("\n" if combined and not combined.endswith("\n") else "") + stderr
|
|
658
|
+
combined = combined.rstrip()
|
|
659
|
+
if len(combined) > self.max_output_chars:
|
|
660
|
+
combined = _truncate_middle(combined, self.max_output_chars)
|
|
661
|
+
if not combined:
|
|
662
|
+
combined = "(no output)"
|
|
663
|
+
ok = proc.returncode == 0
|
|
664
|
+
header = f"exit code {proc.returncode} (cwd {self._display(self.root) or '.'})"
|
|
665
|
+
return ToolResult(
|
|
666
|
+
name="run_command",
|
|
667
|
+
arguments=args,
|
|
668
|
+
ok=ok,
|
|
669
|
+
output=header + "\n" + combined,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
@property
|
|
673
|
+
def changed_files(self) -> list[str]:
|
|
674
|
+
return list(self._changed)
|