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/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)