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.
Files changed (71) hide show
  1. scootcli/__init__.py +4 -0
  2. scootcli/__main__.py +9 -0
  3. scootcli/activity.py +26 -0
  4. scootcli/agent.py +350 -0
  5. scootcli/approvals.py +167 -0
  6. scootcli/auth.py +59 -0
  7. scootcli/cli.py +276 -0
  8. scootcli/clipboard.py +89 -0
  9. scootcli/commands/__init__.py +56 -0
  10. scootcli/commands/approve.py +38 -0
  11. scootcli/commands/auth.py +107 -0
  12. scootcli/commands/base.py +31 -0
  13. scootcli/commands/compact.py +40 -0
  14. scootcli/commands/copy.py +23 -0
  15. scootcli/commands/exit.py +14 -0
  16. scootcli/commands/forget.py +28 -0
  17. scootcli/commands/help.py +29 -0
  18. scootcli/commands/init.py +50 -0
  19. scootcli/commands/logo.py +51 -0
  20. scootcli/commands/model.py +61 -0
  21. scootcli/commands/panel.py +28 -0
  22. scootcli/commands/reset.py +20 -0
  23. scootcli/commands/resume.py +31 -0
  24. scootcli/commands/save.py +29 -0
  25. scootcli/commands/sessions.py +42 -0
  26. scootcli/commands/status.py +59 -0
  27. scootcli/commands/verbosity.py +57 -0
  28. scootcli/commands/worktree.py +64 -0
  29. scootcli/commands/yolo.py +20 -0
  30. scootcli/config.py +241 -0
  31. scootcli/context.py +82 -0
  32. scootcli/credentials.py +79 -0
  33. scootcli/errors.py +87 -0
  34. scootcli/images.py +169 -0
  35. scootcli/keys.py +119 -0
  36. scootcli/lineeditor.py +577 -0
  37. scootcli/logo.py +116 -0
  38. scootcli/models.py +120 -0
  39. scootcli/panel.py +263 -0
  40. scootcli/preferences.py +87 -0
  41. scootcli/presets.py +38 -0
  42. scootcli/project.py +94 -0
  43. scootcli/prompts.py +100 -0
  44. scootcli/providers/__init__.py +20 -0
  45. scootcli/providers/base.py +370 -0
  46. scootcli/providers/openai_chat.py +142 -0
  47. scootcli/providers/openai_responses.py +248 -0
  48. scootcli/providers/registry.py +173 -0
  49. scootcli/rendering.py +86 -0
  50. scootcli/repl.py +801 -0
  51. scootcli/sessions.py +186 -0
  52. scootcli/status.py +71 -0
  53. scootcli/tools/__init__.py +68 -0
  54. scootcli/tools/base.py +152 -0
  55. scootcli/tools/edit_file.py +72 -0
  56. scootcli/tools/list_dir.py +47 -0
  57. scootcli/tools/read_file.py +56 -0
  58. scootcli/tools/run_shell.py +73 -0
  59. scootcli/tools/search.py +170 -0
  60. scootcli/tools/update_plan.py +104 -0
  61. scootcli/tools/write_file.py +61 -0
  62. scootcli/transport.py +312 -0
  63. scootcli/vision.py +167 -0
  64. scootcli/workspace.py +105 -0
  65. scootcli/worktree.py +114 -0
  66. scootcli-0.1.0.dist-info/METADATA +238 -0
  67. scootcli-0.1.0.dist-info/RECORD +71 -0
  68. scootcli-0.1.0.dist-info/WHEEL +5 -0
  69. scootcli-0.1.0.dist-info/entry_points.txt +2 -0
  70. scootcli-0.1.0.dist-info/licenses/LICENSE +21 -0
  71. 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
+