scootcli 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.
- scootcli/__init__.py +4 -0
- scootcli/__main__.py +9 -0
- scootcli/activity.py +26 -0
- scootcli/agent.py +350 -0
- scootcli/approvals.py +167 -0
- scootcli/auth.py +59 -0
- scootcli/cli.py +276 -0
- scootcli/clipboard.py +89 -0
- scootcli/commands/__init__.py +56 -0
- scootcli/commands/approve.py +38 -0
- scootcli/commands/auth.py +107 -0
- scootcli/commands/base.py +31 -0
- scootcli/commands/compact.py +40 -0
- scootcli/commands/copy.py +23 -0
- scootcli/commands/exit.py +14 -0
- scootcli/commands/forget.py +28 -0
- scootcli/commands/help.py +29 -0
- scootcli/commands/init.py +50 -0
- scootcli/commands/logo.py +51 -0
- scootcli/commands/model.py +61 -0
- scootcli/commands/panel.py +28 -0
- scootcli/commands/reset.py +20 -0
- scootcli/commands/resume.py +31 -0
- scootcli/commands/save.py +29 -0
- scootcli/commands/sessions.py +42 -0
- scootcli/commands/status.py +59 -0
- scootcli/commands/verbosity.py +57 -0
- scootcli/commands/worktree.py +64 -0
- scootcli/commands/yolo.py +20 -0
- scootcli/config.py +241 -0
- scootcli/context.py +82 -0
- scootcli/credentials.py +79 -0
- scootcli/errors.py +87 -0
- scootcli/images.py +169 -0
- scootcli/keys.py +119 -0
- scootcli/lineeditor.py +577 -0
- scootcli/logo.py +116 -0
- scootcli/models.py +120 -0
- scootcli/panel.py +263 -0
- scootcli/preferences.py +87 -0
- scootcli/presets.py +38 -0
- scootcli/project.py +94 -0
- scootcli/prompts.py +100 -0
- scootcli/providers/__init__.py +20 -0
- scootcli/providers/base.py +370 -0
- scootcli/providers/openai_chat.py +142 -0
- scootcli/providers/openai_responses.py +248 -0
- scootcli/providers/registry.py +173 -0
- scootcli/rendering.py +86 -0
- scootcli/repl.py +801 -0
- scootcli/sessions.py +186 -0
- scootcli/status.py +71 -0
- scootcli/tools/__init__.py +68 -0
- scootcli/tools/base.py +152 -0
- scootcli/tools/edit_file.py +72 -0
- scootcli/tools/list_dir.py +47 -0
- scootcli/tools/read_file.py +56 -0
- scootcli/tools/run_shell.py +73 -0
- scootcli/tools/search.py +170 -0
- scootcli/tools/update_plan.py +104 -0
- scootcli/tools/write_file.py +61 -0
- scootcli/transport.py +312 -0
- scootcli/vision.py +167 -0
- scootcli/workspace.py +105 -0
- scootcli/worktree.py +114 -0
- scootcli-0.1.0.dist-info/METADATA +238 -0
- scootcli-0.1.0.dist-info/RECORD +71 -0
- scootcli-0.1.0.dist-info/WHEEL +5 -0
- scootcli-0.1.0.dist-info/entry_points.txt +2 -0
- scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
- scootcli-0.1.0.dist-info/top_level.txt +1 -0
scootcli/sessions.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Session persistence: auto-save each turn, resume with ``--continue`` / ``--resume`` (PLAN §14).
|
|
2
|
+
|
|
3
|
+
Stdlib-only and offline-friendly. Each interactive session is auto-saved after every turn to
|
|
4
|
+
``~/.local/state/scoot/sessions/<id>.json`` (owner-only, ``0600``), keyed by workspace root so
|
|
5
|
+
``--continue`` can find the most recent conversation for the directory you're in. Token-like strings
|
|
6
|
+
are redacted before writing (defense in depth); only the most recent :data:`SESSION_RETENTION`
|
|
7
|
+
sessions are kept.
|
|
8
|
+
|
|
9
|
+
The state directory can be overridden with ``SCOOT_STATE_DIR`` (used by tests).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Optional
|
|
20
|
+
|
|
21
|
+
from .config import SESSION_RETENTION, STATE_DIR
|
|
22
|
+
from .rendering import redact
|
|
23
|
+
|
|
24
|
+
_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _sessions_dir() -> Path:
|
|
28
|
+
override = os.environ.get("SCOOT_STATE_DIR")
|
|
29
|
+
base = Path(override).expanduser() if override else STATE_DIR
|
|
30
|
+
return base / "sessions"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def new_session_id() -> str:
|
|
34
|
+
"""A sortable, unique id: ``YYYYmmdd-HHMMSS-<rand>``."""
|
|
35
|
+
return time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _redact_messages(messages: List[dict]) -> List[dict]:
|
|
39
|
+
"""Copy messages, masking token-like substrings in string content fields."""
|
|
40
|
+
cleaned: List[dict] = []
|
|
41
|
+
for msg in messages:
|
|
42
|
+
m = dict(msg)
|
|
43
|
+
content = m.get("content")
|
|
44
|
+
if isinstance(content, str):
|
|
45
|
+
m["content"] = redact(content)
|
|
46
|
+
cleaned.append(m)
|
|
47
|
+
return cleaned
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SessionRecord:
|
|
52
|
+
"""A persisted conversation + the session settings needed to resume it."""
|
|
53
|
+
|
|
54
|
+
id: str
|
|
55
|
+
root: str
|
|
56
|
+
created: float
|
|
57
|
+
updated: float
|
|
58
|
+
model: str
|
|
59
|
+
active_model: str
|
|
60
|
+
approval_mode: str
|
|
61
|
+
total_prompt: int = 0
|
|
62
|
+
total_completion: int = 0
|
|
63
|
+
messages: List[dict] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def turns(self) -> int:
|
|
67
|
+
return sum(1 for m in self.messages if m.get("role") == "user")
|
|
68
|
+
|
|
69
|
+
def first_prompt(self) -> str:
|
|
70
|
+
for m in self.messages:
|
|
71
|
+
if m.get("role") == "user" and isinstance(m.get("content"), str):
|
|
72
|
+
text = m["content"].strip().replace("\n", " ")
|
|
73
|
+
return (text[:57] + "…") if len(text) > 58 else text
|
|
74
|
+
return "(no prompt yet)"
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict:
|
|
77
|
+
return {
|
|
78
|
+
"version": _VERSION,
|
|
79
|
+
"id": self.id,
|
|
80
|
+
"root": self.root,
|
|
81
|
+
"created": self.created,
|
|
82
|
+
"updated": self.updated,
|
|
83
|
+
"model": self.model,
|
|
84
|
+
"active_model": self.active_model,
|
|
85
|
+
"approval_mode": self.approval_mode,
|
|
86
|
+
"total_prompt": self.total_prompt,
|
|
87
|
+
"total_completion": self.total_completion,
|
|
88
|
+
"messages": self.messages,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_dict(cls, data: dict) -> "SessionRecord":
|
|
93
|
+
return cls(
|
|
94
|
+
id=data["id"],
|
|
95
|
+
root=data.get("root", ""),
|
|
96
|
+
created=data.get("created", 0.0),
|
|
97
|
+
updated=data.get("updated", 0.0),
|
|
98
|
+
model=data.get("model", "auto"),
|
|
99
|
+
active_model=data.get("active_model", ""),
|
|
100
|
+
approval_mode=data.get("approval_mode", "always"),
|
|
101
|
+
total_prompt=int(data.get("total_prompt", 0) or 0),
|
|
102
|
+
total_completion=int(data.get("total_completion", 0) or 0),
|
|
103
|
+
messages=data.get("messages", []),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def save(record: SessionRecord) -> Optional[Path]:
|
|
108
|
+
"""Persist ``record`` (owner-only), prune old sessions, and return the path (best-effort)."""
|
|
109
|
+
record.updated = time.time()
|
|
110
|
+
payload = record.to_dict()
|
|
111
|
+
payload["messages"] = _redact_messages(record.messages)
|
|
112
|
+
|
|
113
|
+
directory = _sessions_dir()
|
|
114
|
+
try:
|
|
115
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
try:
|
|
117
|
+
os.chmod(directory.parent, 0o700)
|
|
118
|
+
os.chmod(directory, 0o700)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
path = directory / f"{record.id}.json"
|
|
122
|
+
tmp = directory / f".{record.id}.tmp"
|
|
123
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
124
|
+
with os.fdopen(fd, "w") as fh:
|
|
125
|
+
json.dump(payload, fh)
|
|
126
|
+
os.replace(tmp, path) # atomic
|
|
127
|
+
_prune()
|
|
128
|
+
return path
|
|
129
|
+
except OSError:
|
|
130
|
+
return None # persistence must never break the REPL
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def load(session_id: str) -> Optional[SessionRecord]:
|
|
134
|
+
try:
|
|
135
|
+
data = json.loads((_sessions_dir() / f"{session_id}.json").read_text())
|
|
136
|
+
return SessionRecord.from_dict(data)
|
|
137
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def list_sessions(root: Optional[str] = None) -> List[SessionRecord]:
|
|
142
|
+
"""Return saved sessions (optionally filtered to ``root``), newest first."""
|
|
143
|
+
directory = _sessions_dir()
|
|
144
|
+
records: List[SessionRecord] = []
|
|
145
|
+
try:
|
|
146
|
+
files = list(directory.glob("*.json"))
|
|
147
|
+
except OSError:
|
|
148
|
+
return []
|
|
149
|
+
for f in files:
|
|
150
|
+
try:
|
|
151
|
+
rec = SessionRecord.from_dict(json.loads(f.read_text()))
|
|
152
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
153
|
+
continue
|
|
154
|
+
if root is None or rec.root == str(root):
|
|
155
|
+
records.append(rec)
|
|
156
|
+
records.sort(key=lambda r: r.updated, reverse=True)
|
|
157
|
+
return records
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def latest_for_root(root: str) -> Optional[SessionRecord]:
|
|
161
|
+
sessions = list_sessions(str(root))
|
|
162
|
+
return sessions[0] if sessions else None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def delete(session_id: str) -> bool:
|
|
166
|
+
try:
|
|
167
|
+
(_sessions_dir() / f"{session_id}.json").unlink()
|
|
168
|
+
return True
|
|
169
|
+
except (FileNotFoundError, OSError):
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def delete_all(root: Optional[str] = None) -> int:
|
|
174
|
+
count = 0
|
|
175
|
+
for rec in list_sessions(root):
|
|
176
|
+
if delete(rec.id):
|
|
177
|
+
count += 1
|
|
178
|
+
return count
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _prune() -> None:
|
|
182
|
+
"""Keep only the most recent :data:`SESSION_RETENTION` sessions (across all roots)."""
|
|
183
|
+
everything = list_sessions()
|
|
184
|
+
for rec in everything[SESSION_RETENTION:]:
|
|
185
|
+
delete(rec.id)
|
|
186
|
+
|
scootcli/status.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Live status region: a single-line spinner + activity message (PLAN §6, §10).
|
|
2
|
+
|
|
3
|
+
Stdlib only — uses carriage-return redraws and ANSI. Runs in its own thread so the message keeps
|
|
4
|
+
animating while the model call / tool runs. Auto-disables when stdout is not a TTY.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
from .rendering import color
|
|
14
|
+
|
|
15
|
+
_FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
16
|
+
_CLEAR_LINE = "\r\033[K"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Status:
|
|
20
|
+
"""A threaded spinner with an updatable message and a persistent step hint."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, enabled: bool = True):
|
|
23
|
+
self.enabled = enabled and sys.stdout.isatty()
|
|
24
|
+
self._message = ""
|
|
25
|
+
self._hint = ""
|
|
26
|
+
self._thread = None
|
|
27
|
+
self._stop = threading.Event()
|
|
28
|
+
self._lock = threading.Lock()
|
|
29
|
+
|
|
30
|
+
def start(self, message: str = "thinking…", hint: str = "esc to stop") -> None:
|
|
31
|
+
self._message = message
|
|
32
|
+
self._hint = hint
|
|
33
|
+
if not self.enabled:
|
|
34
|
+
return
|
|
35
|
+
self._stop.clear()
|
|
36
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
37
|
+
self._thread.start()
|
|
38
|
+
|
|
39
|
+
def update(self, message: str, hint: str = None) -> None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
self._message = message
|
|
42
|
+
if hint is not None:
|
|
43
|
+
self._hint = hint
|
|
44
|
+
|
|
45
|
+
def _spin(self) -> None:
|
|
46
|
+
i = 0
|
|
47
|
+
while not self._stop.is_set():
|
|
48
|
+
with self._lock:
|
|
49
|
+
message, hint = self._message, self._hint
|
|
50
|
+
frame = color(_FRAMES[i % len(_FRAMES)], "cyan")
|
|
51
|
+
tail = color(f" · {hint}", "gray") if hint else ""
|
|
52
|
+
sys.stdout.write(f"{_CLEAR_LINE} {frame} {message}{tail}")
|
|
53
|
+
sys.stdout.flush()
|
|
54
|
+
i += 1
|
|
55
|
+
time.sleep(0.08)
|
|
56
|
+
|
|
57
|
+
def stop(self) -> None:
|
|
58
|
+
self._stop.set()
|
|
59
|
+
if self._thread is not None:
|
|
60
|
+
self._thread.join(timeout=0.3)
|
|
61
|
+
self._thread = None
|
|
62
|
+
if self.enabled:
|
|
63
|
+
sys.stdout.write(_CLEAR_LINE)
|
|
64
|
+
sys.stdout.flush()
|
|
65
|
+
|
|
66
|
+
def line(self, text: str) -> None:
|
|
67
|
+
"""Print a permanent line, clearing the spinner first so it doesn't get mangled."""
|
|
68
|
+
if self.enabled:
|
|
69
|
+
sys.stdout.write(_CLEAR_LINE)
|
|
70
|
+
print(text)
|
|
71
|
+
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Tool registry. Drop a new module in this package and call :func:`register` to add a tool.
|
|
2
|
+
|
|
3
|
+
The registry is the single source of truth: it feeds both the runtime ``tools`` schema sent to the
|
|
4
|
+
API and the ``{{tool_list}}`` rendered into the system prompt (PLAN §2, §8).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .base import Tool, ToolContext, ToolError, ToolResult
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Tool",
|
|
15
|
+
"ToolContext",
|
|
16
|
+
"ToolResult",
|
|
17
|
+
"ToolError",
|
|
18
|
+
"register",
|
|
19
|
+
"get",
|
|
20
|
+
"all_tools",
|
|
21
|
+
"schemas",
|
|
22
|
+
"tool_list_text",
|
|
23
|
+
"load_builtins",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
_REGISTRY: Dict[str, Tool] = {}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def register(tool: Tool) -> None:
|
|
30
|
+
_REGISTRY[tool.name] = tool
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get(name: str) -> Optional[Tool]:
|
|
34
|
+
return _REGISTRY.get(name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def all_tools() -> Dict[str, Tool]:
|
|
38
|
+
return dict(sorted(_REGISTRY.items()))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def schemas() -> List[dict]:
|
|
42
|
+
"""Return the OpenAI-style tool schemas for the request ``tools`` param."""
|
|
43
|
+
return [t.schema() for t in all_tools().values()]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool_list_text() -> str:
|
|
47
|
+
"""One-line-per-tool summary for the system prompt ``{{tool_list}}``."""
|
|
48
|
+
return "\n".join(f"- {t.name}: {t.description}" for t in all_tools().values())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_loaded = False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_builtins() -> None:
|
|
55
|
+
"""Import the built-in tool modules so they self-register (idempotent)."""
|
|
56
|
+
global _loaded
|
|
57
|
+
if _loaded:
|
|
58
|
+
return
|
|
59
|
+
from . import read_file # noqa: F401
|
|
60
|
+
from . import list_dir # noqa: F401
|
|
61
|
+
from . import search # noqa: F401
|
|
62
|
+
from . import write_file # noqa: F401
|
|
63
|
+
from . import edit_file # noqa: F401
|
|
64
|
+
from . import run_shell # noqa: F401
|
|
65
|
+
from . import update_plan # noqa: F401
|
|
66
|
+
|
|
67
|
+
_loaded = True
|
|
68
|
+
|
scootcli/tools/base.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Tool foundation: interfaces, sandboxing, truncation, and an interruptible subprocess helper.
|
|
2
|
+
|
|
3
|
+
Each tool is a small object exposing a JSON schema (for the API ``tools`` param) plus a ``run``
|
|
4
|
+
handler. Tools are sandboxed to the workspace root and return a structured :class:`ToolResult` so the
|
|
5
|
+
agent loop can feed errors back to the model instead of crashing (PLAN §8).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import subprocess
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
from ..errors import ScootError, Interrupted
|
|
17
|
+
|
|
18
|
+
# ── Limits (keep tool output and reads bounded) ────────────────────────────────
|
|
19
|
+
MAX_READ_BYTES = 200 * 1024 # 200 KB per read_file
|
|
20
|
+
MAX_OUTPUT_CHARS = 16_000 # truncate tool output fed back to the model
|
|
21
|
+
MAX_OUTPUT_LINES = 400
|
|
22
|
+
DEFAULT_SHELL_TIMEOUT = 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolError(ScootError):
|
|
26
|
+
"""A tool failed in a way worth surfacing (returned as ToolResult, not raised to the user)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PathEscapeError(ToolError):
|
|
30
|
+
"""A path resolved outside the workspace root."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ToolContext:
|
|
35
|
+
"""Ambient info passed to every tool invocation."""
|
|
36
|
+
|
|
37
|
+
root: Path
|
|
38
|
+
config: Any = None
|
|
39
|
+
cancel_event: Optional[threading.Event] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ToolResult:
|
|
44
|
+
"""Structured result of a tool call."""
|
|
45
|
+
|
|
46
|
+
ok: bool
|
|
47
|
+
content: str = "" # text fed back to the model
|
|
48
|
+
error: str = "" # populated when ok is False
|
|
49
|
+
summary: str = "" # short human line for the status region, e.g. "42 lines"
|
|
50
|
+
meta: dict = field(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def fail(cls, error: str) -> "ToolResult":
|
|
54
|
+
return cls(ok=False, error=error, summary=error)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Tool:
|
|
58
|
+
"""Base class for tools. Subclasses set ``name``/``description``/``parameters`` and impl ``run``."""
|
|
59
|
+
|
|
60
|
+
name: str = ""
|
|
61
|
+
description: str = ""
|
|
62
|
+
parameters: dict = {"type": "object", "properties": {}}
|
|
63
|
+
risk: str = "read" # "read" | "write" | "shell" — drives the approval UI emphasis
|
|
64
|
+
auto_approve: bool = False # meta tools with no side effects (e.g. update_plan) skip approval
|
|
65
|
+
|
|
66
|
+
def run(self, args: dict, ctx: ToolContext) -> ToolResult: # pragma: no cover - abstract
|
|
67
|
+
raise NotImplementedError
|
|
68
|
+
|
|
69
|
+
def preview(self, args: dict, ctx: ToolContext) -> Optional[str]:
|
|
70
|
+
"""Optional rich preview shown at approval time (e.g. a diff). ``None`` = args-only."""
|
|
71
|
+
return None
|
|
72
|
+
|
|
73
|
+
def schema(self) -> dict:
|
|
74
|
+
"""OpenAI-style function schema for the ``tools`` request param."""
|
|
75
|
+
return {
|
|
76
|
+
"type": "function",
|
|
77
|
+
"function": {
|
|
78
|
+
"name": self.name,
|
|
79
|
+
"description": self.description,
|
|
80
|
+
"parameters": self.parameters,
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Sandboxing ─────────────────────────────────────────────────────────────────
|
|
86
|
+
def safe_path(root: Path, raw: str) -> Path:
|
|
87
|
+
"""Resolve ``raw`` against ``root`` and ensure it stays inside it. Raises PathEscapeError."""
|
|
88
|
+
if raw is None:
|
|
89
|
+
raise ToolError("missing 'path'")
|
|
90
|
+
root = root.resolve()
|
|
91
|
+
p = Path(raw)
|
|
92
|
+
if not p.is_absolute():
|
|
93
|
+
p = root / p
|
|
94
|
+
p = p.resolve()
|
|
95
|
+
try:
|
|
96
|
+
p.relative_to(root)
|
|
97
|
+
except ValueError:
|
|
98
|
+
raise PathEscapeError(f"path escapes workspace root: {raw}")
|
|
99
|
+
return p
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── Truncation ─────────────────────────────────────────────────────────────────
|
|
103
|
+
def truncate(text: str, max_chars: int = MAX_OUTPUT_CHARS, max_lines: int = MAX_OUTPUT_LINES) -> str:
|
|
104
|
+
"""Bound text by lines and chars, appending a clear truncation notice when cut."""
|
|
105
|
+
lines = text.splitlines()
|
|
106
|
+
cut = False
|
|
107
|
+
if len(lines) > max_lines:
|
|
108
|
+
lines = lines[:max_lines]
|
|
109
|
+
cut = True
|
|
110
|
+
out = "\n".join(lines)
|
|
111
|
+
if len(out) > max_chars:
|
|
112
|
+
out = out[:max_chars]
|
|
113
|
+
cut = True
|
|
114
|
+
if cut:
|
|
115
|
+
out += "\n… [output truncated]"
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ── Interruptible subprocess (shared by run_shell + ripgrep search) ────────────
|
|
120
|
+
def run_subprocess(
|
|
121
|
+
cmd: list,
|
|
122
|
+
cwd: Path,
|
|
123
|
+
cancel_event: Optional[threading.Event] = None,
|
|
124
|
+
timeout: int = DEFAULT_SHELL_TIMEOUT,
|
|
125
|
+
) -> "tuple[int, str, str]":
|
|
126
|
+
"""Run ``cmd`` returning ``(returncode, stdout, stderr)``.
|
|
127
|
+
|
|
128
|
+
Honors ``cancel_event`` (ESC) by terminating the process, and enforces ``timeout``.
|
|
129
|
+
"""
|
|
130
|
+
import time
|
|
131
|
+
|
|
132
|
+
proc = subprocess.Popen(
|
|
133
|
+
cmd, cwd=str(cwd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
|
|
134
|
+
)
|
|
135
|
+
deadline = time.time() + timeout
|
|
136
|
+
while True:
|
|
137
|
+
try:
|
|
138
|
+
stdout, stderr = proc.communicate(timeout=0.1)
|
|
139
|
+
return proc.returncode, stdout, stderr
|
|
140
|
+
except subprocess.TimeoutExpired:
|
|
141
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
142
|
+
proc.terminate()
|
|
143
|
+
try:
|
|
144
|
+
proc.communicate(timeout=2)
|
|
145
|
+
except subprocess.TimeoutExpired:
|
|
146
|
+
proc.kill()
|
|
147
|
+
raise Interrupted("tool cancelled by user")
|
|
148
|
+
if time.time() > deadline:
|
|
149
|
+
proc.kill()
|
|
150
|
+
proc.communicate()
|
|
151
|
+
raise ToolError(f"command timed out after {timeout}s")
|
|
152
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""edit_file — surgical unique-match replacement in an existing file (with diff preview)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..rendering import diff_stats, unified_diff
|
|
6
|
+
from . import register
|
|
7
|
+
from .base import Tool, ToolContext, ToolError, ToolResult, safe_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class EditFile(Tool):
|
|
11
|
+
name = "edit_file"
|
|
12
|
+
description = (
|
|
13
|
+
"Replace an exact, unique snippet in an existing file. 'old_string' must occur exactly once."
|
|
14
|
+
)
|
|
15
|
+
risk = "write"
|
|
16
|
+
parameters = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"path": {"type": "string", "description": "File path relative to the workspace root."},
|
|
20
|
+
"old_string": {"type": "string", "description": "Exact text to replace (must be unique)."},
|
|
21
|
+
"new_string": {"type": "string", "description": "Replacement text."},
|
|
22
|
+
},
|
|
23
|
+
"required": ["path", "old_string", "new_string"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def _compute(self, args, ctx):
|
|
27
|
+
"""Return (path, old_text, new_text). Raises ToolError on any problem."""
|
|
28
|
+
path = safe_path(ctx.root, args.get("path"))
|
|
29
|
+
if not path.exists() or not path.is_file():
|
|
30
|
+
raise ToolError(f"no such file: {args.get('path')}")
|
|
31
|
+
old_string = args.get("old_string")
|
|
32
|
+
new_string = args.get("new_string")
|
|
33
|
+
if old_string is None or new_string is None:
|
|
34
|
+
raise ToolError("both 'old_string' and 'new_string' are required")
|
|
35
|
+
old_text = path.read_text("utf-8", "replace")
|
|
36
|
+
occurrences = old_text.count(old_string)
|
|
37
|
+
if occurrences == 0:
|
|
38
|
+
raise ToolError("old_string not found in file")
|
|
39
|
+
if occurrences > 1:
|
|
40
|
+
raise ToolError(f"old_string is not unique ({occurrences} matches); add more context")
|
|
41
|
+
new_text = old_text.replace(old_string, new_string, 1)
|
|
42
|
+
return path, old_text, new_text
|
|
43
|
+
|
|
44
|
+
def preview(self, args: dict, ctx: ToolContext):
|
|
45
|
+
try:
|
|
46
|
+
_, old_text, new_text = self._compute(args, ctx)
|
|
47
|
+
except ToolError as exc:
|
|
48
|
+
return str(exc)
|
|
49
|
+
rel = args.get("path")
|
|
50
|
+
added, removed = diff_stats(old_text, new_text)
|
|
51
|
+
return f"edit {rel} (+{added} -{removed})\n" + unified_diff(old_text, new_text, rel)
|
|
52
|
+
|
|
53
|
+
def run(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
54
|
+
try:
|
|
55
|
+
path, old_text, new_text = self._compute(args, ctx)
|
|
56
|
+
except ToolError as exc:
|
|
57
|
+
return ToolResult.fail(str(exc))
|
|
58
|
+
try:
|
|
59
|
+
path.write_text(new_text, "utf-8")
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
return ToolResult.fail(f"write failed: {exc}")
|
|
62
|
+
added, removed = diff_stats(old_text, new_text)
|
|
63
|
+
return ToolResult(
|
|
64
|
+
ok=True,
|
|
65
|
+
content=f"edited {args.get('path')} (+{added} -{removed})",
|
|
66
|
+
summary=f"{args.get('path')} +{added} -{removed}",
|
|
67
|
+
meta={"added": added, "removed": removed},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
register(EditFile())
|
|
72
|
+
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""list_dir — list a directory's contents, sandboxed to the workspace root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import register
|
|
6
|
+
from .base import Tool, ToolContext, ToolError, ToolResult, safe_path, truncate
|
|
7
|
+
|
|
8
|
+
_HIDDEN = {".git", ".hg", ".svn", "__pycache__", ".mypy_cache", ".pytest_cache", "node_modules"}
|
|
9
|
+
_MAX_ENTRIES = 300
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ListDir(Tool):
|
|
13
|
+
name = "list_dir"
|
|
14
|
+
description = "List the entries of a directory within the workspace (directories end with '/')."
|
|
15
|
+
risk = "read"
|
|
16
|
+
parameters = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"path": {"type": "string", "description": "Directory path (default: workspace root)."},
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
def run(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
24
|
+
raw = args.get("path") or "."
|
|
25
|
+
try:
|
|
26
|
+
path = safe_path(ctx.root, raw)
|
|
27
|
+
except ToolError as exc:
|
|
28
|
+
return ToolResult.fail(str(exc))
|
|
29
|
+
if not path.exists():
|
|
30
|
+
return ToolResult.fail(f"no such directory: {raw}")
|
|
31
|
+
if not path.is_dir():
|
|
32
|
+
return ToolResult.fail(f"not a directory: {raw}")
|
|
33
|
+
|
|
34
|
+
entries = []
|
|
35
|
+
for child in sorted(path.iterdir(), key=lambda c: (c.is_file(), c.name.lower())):
|
|
36
|
+
if child.name in _HIDDEN:
|
|
37
|
+
continue
|
|
38
|
+
entries.append(child.name + ("/" if child.is_dir() else ""))
|
|
39
|
+
|
|
40
|
+
shown = entries[:_MAX_ENTRIES]
|
|
41
|
+
note = "" if len(entries) <= _MAX_ENTRIES else f"\n… [{len(entries) - _MAX_ENTRIES} more]"
|
|
42
|
+
content = truncate("\n".join(shown)) + note
|
|
43
|
+
return ToolResult(ok=True, content=content or "(empty)", summary=f"{len(entries)} entries")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
register(ListDir())
|
|
47
|
+
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""read_file — read a text file (optionally a line range), sandboxed to the workspace root."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import register
|
|
6
|
+
from .base import MAX_READ_BYTES, Tool, ToolContext, ToolError, ToolResult, safe_path, truncate
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ReadFile(Tool):
|
|
10
|
+
name = "read_file"
|
|
11
|
+
description = (
|
|
12
|
+
"Read a UTF-8 text file within the workspace, optionally a line range. "
|
|
13
|
+
"Returns line-numbered content."
|
|
14
|
+
)
|
|
15
|
+
risk = "read"
|
|
16
|
+
parameters = {
|
|
17
|
+
"type": "object",
|
|
18
|
+
"properties": {
|
|
19
|
+
"path": {"type": "string", "description": "File path relative to the workspace root."},
|
|
20
|
+
"start": {"type": "integer", "description": "1-based first line (optional)."},
|
|
21
|
+
"end": {"type": "integer", "description": "1-based last line, inclusive (optional)."},
|
|
22
|
+
},
|
|
23
|
+
"required": ["path"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def run(self, args: dict, ctx: ToolContext) -> ToolResult:
|
|
27
|
+
try:
|
|
28
|
+
path = safe_path(ctx.root, args.get("path"))
|
|
29
|
+
except ToolError as exc:
|
|
30
|
+
return ToolResult.fail(str(exc))
|
|
31
|
+
if not path.exists():
|
|
32
|
+
return ToolResult.fail(f"no such file: {args.get('path')}")
|
|
33
|
+
if path.is_dir():
|
|
34
|
+
return ToolResult.fail(f"is a directory: {args.get('path')}")
|
|
35
|
+
|
|
36
|
+
data = path.read_bytes()
|
|
37
|
+
note = ""
|
|
38
|
+
if len(data) > MAX_READ_BYTES:
|
|
39
|
+
data = data[:MAX_READ_BYTES]
|
|
40
|
+
note = " [file truncated to 200 KB]"
|
|
41
|
+
lines = data.decode("utf-8", "replace").splitlines()
|
|
42
|
+
|
|
43
|
+
total = len(lines)
|
|
44
|
+
start = max(1, int(args.get("start") or 1))
|
|
45
|
+
end = int(args.get("end") or total)
|
|
46
|
+
end = min(end, total)
|
|
47
|
+
selected = lines[start - 1 : end]
|
|
48
|
+
|
|
49
|
+
width = len(str(end))
|
|
50
|
+
numbered = "\n".join(f"{i:>{width}} | {line}" for i, line in enumerate(selected, start))
|
|
51
|
+
summary = f"{len(selected)} lines" + (f" ({start}-{end} of {total})" if selected else "")
|
|
52
|
+
return ToolResult(ok=True, content=truncate(numbered) + note, summary=summary)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
register(ReadFile())
|
|
56
|
+
|