seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""Desktop tools: the Computer Engine's AI-facing contract.
|
|
2
|
+
|
|
3
|
+
This module is the *entire* surface the AI has on the local machine, and it is
|
|
4
|
+
deliberately narrow. The AI reasons and decides; the deterministic
|
|
5
|
+
:class:`~seedcode.computer.engine.ComputerEngine` does the work. So the AI never
|
|
6
|
+
moves the mouse, presses keys, waits for windows, retries, or handles OCR —
|
|
7
|
+
those live below this line.
|
|
8
|
+
|
|
9
|
+
What the AI can call:
|
|
10
|
+
|
|
11
|
+
* ``computer_run`` — run a high-level skill from the catalog (the main verb).
|
|
12
|
+
* ``ui_click`` / ``ui_double_click`` / ``ui_right_click`` / ``ui_type`` /
|
|
13
|
+
``ui_wait_for`` / ``ui_assert`` — semantic UI actions whose targets are
|
|
14
|
+
*descriptions* ("the Submit button"); the engine resolves coordinates.
|
|
15
|
+
* ``computer_state`` — read engine memory (focused app, pointer, recent work).
|
|
16
|
+
* ``computer_see`` — a semantic snapshot to replan against unknown UI.
|
|
17
|
+
* ``desktop_screenshot`` / ``desktop_windows`` / ``desktop_screen_info`` —
|
|
18
|
+
observation for vision providers and replanning.
|
|
19
|
+
|
|
20
|
+
No coordinate-, keystroke-, or selector-bearing argument is exposed. Capability
|
|
21
|
+
is gated by the unified :class:`~seedcode.tools.permissions.PermissionLevel`
|
|
22
|
+
(desktop needs ``DESKTOP``; sensitive skills need ``FULL_SYSTEM``), and every
|
|
23
|
+
sensitive action is confirmed per-action through the session's Desktop Control
|
|
24
|
+
gate.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import TYPE_CHECKING, Any
|
|
30
|
+
|
|
31
|
+
from .base import ToolError, ToolResult, register
|
|
32
|
+
from .permissions import PermissionLevel
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..computer.engine import ComputerEngine
|
|
36
|
+
from .permissions import PermissionManager
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Shared lazy controller: one instance per session, reused by the engine and
|
|
40
|
+
# by the hand commands (/screenshot, /windows, /computer) that don't need a
|
|
41
|
+
# permission gate. Built on first use so importing this module never pulls in
|
|
42
|
+
# the desktop libraries.
|
|
43
|
+
_controller: "Any | None" = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_controller() -> "Any":
|
|
47
|
+
"""Return the shared :class:`ComputerController`, building it on first use."""
|
|
48
|
+
global _controller
|
|
49
|
+
if _controller is None:
|
|
50
|
+
from ..computer.controller import ComputerController
|
|
51
|
+
|
|
52
|
+
_controller = ComputerController()
|
|
53
|
+
return _controller
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def reset_controller() -> None:
|
|
57
|
+
"""Drop the cached controller and engine (tests inject fakes via this)."""
|
|
58
|
+
global _controller
|
|
59
|
+
_controller = None
|
|
60
|
+
from ..computer import reset_engine
|
|
61
|
+
|
|
62
|
+
reset_engine()
|
|
63
|
+
# Operator-layer engines hold driver references too; drop them so a
|
|
64
|
+
# rebuilt session never reuses stale desktop state.
|
|
65
|
+
from ..computer import screen_state, browser_extract
|
|
66
|
+
|
|
67
|
+
screen_state.reset_screen_engine()
|
|
68
|
+
browser_extract.reset_web_extractor()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _engine(perm: "PermissionManager") -> "ComputerEngine":
|
|
72
|
+
"""Return the session Computer Engine, or raise a model-readable error."""
|
|
73
|
+
from ..computer import get_engine, is_available
|
|
74
|
+
|
|
75
|
+
if not perm.level.allows_desktop:
|
|
76
|
+
raise ToolError(
|
|
77
|
+
"Desktop control needs Desktop permission or higher. The user can "
|
|
78
|
+
"enable it with /assist on or raise it with /permission desktop."
|
|
79
|
+
)
|
|
80
|
+
if perm.desktop is None or not perm.desktop.enabled:
|
|
81
|
+
raise ToolError(
|
|
82
|
+
"Desktop tools are disabled. The user can enable them with /assist on."
|
|
83
|
+
)
|
|
84
|
+
ok, reason = is_available()
|
|
85
|
+
if not ok:
|
|
86
|
+
raise ToolError(f"Desktop control is unavailable: {reason}")
|
|
87
|
+
return get_engine(perm, controller=get_controller())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _confirm_sensitive(perm: "PermissionManager", skill_name: str, params: dict) -> None:
|
|
91
|
+
"""Per-action confirmation for sensitive skills, via the Desktop gate.
|
|
92
|
+
|
|
93
|
+
Sensitive skills additionally require Full System and are never remembered
|
|
94
|
+
("Always" is downgraded to "Once" by the gate), so each one is approved
|
|
95
|
+
individually.
|
|
96
|
+
"""
|
|
97
|
+
from ..computer.permissions import CATEGORY_SYSTEM
|
|
98
|
+
|
|
99
|
+
perm.require(PermissionLevel.FULL_SYSTEM, f"sensitive skill '{skill_name}'")
|
|
100
|
+
desc = f"{skill_name} {params}".strip()
|
|
101
|
+
perm.desktop.check(CATEGORY_SYSTEM, desc) # type: ignore[union-attr]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _gate_control(perm: "PermissionManager", description: str) -> None:
|
|
105
|
+
"""Non-sensitive desktop confirmation (remembered per session)."""
|
|
106
|
+
from ..computer.permissions import CATEGORY_CONTROL
|
|
107
|
+
|
|
108
|
+
perm.desktop.check(CATEGORY_CONTROL, description) # type: ignore[union-attr]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _dispatch(perm: "PermissionManager", name: str, params: dict[str, Any],
|
|
112
|
+
expected: dict[str, Any] | None = None) -> ToolResult:
|
|
113
|
+
"""Shared path for skills and semantic actions: gate, run, report."""
|
|
114
|
+
from ..computer.skills import REGISTRY
|
|
115
|
+
|
|
116
|
+
engine = _engine(perm)
|
|
117
|
+
skill = REGISTRY.get(name)
|
|
118
|
+
if skill is not None and skill.sensitive:
|
|
119
|
+
_confirm_sensitive(perm, skill.name, params)
|
|
120
|
+
else:
|
|
121
|
+
_gate_control(perm, f"{name} {params}".strip())
|
|
122
|
+
|
|
123
|
+
result = engine.run_skill(name, params, expected)
|
|
124
|
+
_queue_screenshot(perm)
|
|
125
|
+
return ToolResult(result.ok, result.for_model())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --- primary verb: run a skill ----------------------------------------------
|
|
129
|
+
@register(
|
|
130
|
+
"computer_run",
|
|
131
|
+
"Run a high-level Computer Engine skill by name (e.g. launch_app, "
|
|
132
|
+
"google_search, create_python_project). The engine expands it into "
|
|
133
|
+
"verified, deterministic steps and recovers from failures on its own — you "
|
|
134
|
+
"choose the skill and its parameters, nothing lower-level. See the skill "
|
|
135
|
+
"catalog in the system prompt for available skills.",
|
|
136
|
+
{
|
|
137
|
+
"skill": "skill name from the catalog",
|
|
138
|
+
"params": "(optional) object of the skill's parameters",
|
|
139
|
+
"expected": "(optional) outcome to verify, e.g. {\"window\": \"Notepad\"}",
|
|
140
|
+
},
|
|
141
|
+
mutates=True,
|
|
142
|
+
group="desktop",
|
|
143
|
+
types={"params": "object", "expected": "object"},
|
|
144
|
+
)
|
|
145
|
+
def _computer_run(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
146
|
+
name = str(args.get("skill", "")).strip()
|
|
147
|
+
if not name:
|
|
148
|
+
return ToolResult(False, "skill is required.")
|
|
149
|
+
params = args.get("params") or {}
|
|
150
|
+
if not isinstance(params, dict):
|
|
151
|
+
return ToolResult(False, "params must be an object.")
|
|
152
|
+
expected = args.get("expected")
|
|
153
|
+
if expected is not None and not isinstance(expected, dict):
|
|
154
|
+
return ToolResult(False, "expected must be an object.")
|
|
155
|
+
return _dispatch(perm, name, params, expected)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# --- semantic UI actions (descriptions, never coordinates) -------------------
|
|
159
|
+
def _semantic(perm: "PermissionManager", verb: str, args: dict[str, Any]) -> ToolResult:
|
|
160
|
+
target = str(args.get("target", "")).strip()
|
|
161
|
+
if not target:
|
|
162
|
+
return ToolResult(False, "target description is required.")
|
|
163
|
+
params: dict[str, Any] = {"target": target}
|
|
164
|
+
if "text" in args:
|
|
165
|
+
params["text"] = args.get("text", "")
|
|
166
|
+
if "secret" in args:
|
|
167
|
+
params["secret"] = str(args.get("secret", "")).lower() in ("true", "1", "yes")
|
|
168
|
+
return _dispatch(perm, verb, params)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@register(
|
|
172
|
+
"ui_click",
|
|
173
|
+
"Click a UI element described in plain words (e.g. \"the Save button\"). "
|
|
174
|
+
"The engine locates it — never pass coordinates.",
|
|
175
|
+
{"target": "description of the element to click"},
|
|
176
|
+
mutates=True,
|
|
177
|
+
group="desktop",
|
|
178
|
+
)
|
|
179
|
+
def _ui_click(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
180
|
+
return _semantic(perm, "ui_click", args)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@register(
|
|
184
|
+
"ui_double_click",
|
|
185
|
+
"Double-click a described UI element (e.g. \"the project folder\").",
|
|
186
|
+
{"target": "description of the element to double-click"},
|
|
187
|
+
mutates=True,
|
|
188
|
+
group="desktop",
|
|
189
|
+
)
|
|
190
|
+
def _ui_double_click(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
191
|
+
return _semantic(perm, "ui_double_click", args)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@register(
|
|
195
|
+
"ui_right_click",
|
|
196
|
+
"Right-click a described UI element to open its context menu.",
|
|
197
|
+
{"target": "description of the element to right-click"},
|
|
198
|
+
mutates=True,
|
|
199
|
+
group="desktop",
|
|
200
|
+
)
|
|
201
|
+
def _ui_right_click(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
202
|
+
return _semantic(perm, "ui_right_click", args)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@register(
|
|
206
|
+
"ui_type",
|
|
207
|
+
"Type text into a described field (e.g. \"the search box\"). Set secret=true "
|
|
208
|
+
"for passwords. The engine focuses the field and types — no coordinates.",
|
|
209
|
+
{
|
|
210
|
+
"target": "description of the field to type into",
|
|
211
|
+
"text": "the text to type",
|
|
212
|
+
"secret": "(optional) true when the text is a password or secret",
|
|
213
|
+
},
|
|
214
|
+
mutates=True,
|
|
215
|
+
group="desktop",
|
|
216
|
+
)
|
|
217
|
+
def _ui_type(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
218
|
+
if "text" not in args:
|
|
219
|
+
return ToolResult(False, "text is required.")
|
|
220
|
+
return _semantic(perm, "ui_type", args)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@register(
|
|
224
|
+
"ui_wait_for",
|
|
225
|
+
"Wait until a described element or window appears before continuing.",
|
|
226
|
+
{"target": "description of what to wait for"},
|
|
227
|
+
mutates=False,
|
|
228
|
+
group="desktop",
|
|
229
|
+
)
|
|
230
|
+
def _ui_wait_for(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
231
|
+
return _semantic(perm, "ui_wait_for", args)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@register(
|
|
235
|
+
"ui_assert",
|
|
236
|
+
"Check that a described element or text is present, without acting on it.",
|
|
237
|
+
{"target": "description of what should be present"},
|
|
238
|
+
mutates=False,
|
|
239
|
+
group="desktop",
|
|
240
|
+
)
|
|
241
|
+
def _ui_assert(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
242
|
+
return _semantic(perm, "ui_assert", args)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
# --- observation -------------------------------------------------------------
|
|
246
|
+
@register(
|
|
247
|
+
"computer_state",
|
|
248
|
+
"Read the Computer Engine's memory: focused app/window, pointer, clipboard, "
|
|
249
|
+
"terminal directory, current project, and recent actions. Prefer this over "
|
|
250
|
+
"re-inspecting the screen — the engine tracks state for you.",
|
|
251
|
+
{},
|
|
252
|
+
mutates=False,
|
|
253
|
+
group="desktop",
|
|
254
|
+
)
|
|
255
|
+
def _computer_state(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
256
|
+
engine = _engine(perm)
|
|
257
|
+
return ToolResult(True, engine.state().describe())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@register(
|
|
261
|
+
"computer_see",
|
|
262
|
+
"Get a semantic snapshot of the active (or named) window: the elements and "
|
|
263
|
+
"visible text, described in words. Use this to replan when the UI is "
|
|
264
|
+
"unfamiliar. Returns descriptions, never coordinates.",
|
|
265
|
+
{"window": "(optional) part of a window title; default: the active window"},
|
|
266
|
+
mutates=False,
|
|
267
|
+
group="desktop",
|
|
268
|
+
)
|
|
269
|
+
def _computer_see(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
270
|
+
engine = _engine(perm)
|
|
271
|
+
window = str(args.get("window", "") or "").strip() or None
|
|
272
|
+
_gate_control(perm, f"inspect UI of {window or 'the active window'}")
|
|
273
|
+
out = engine.see(window)
|
|
274
|
+
_queue_screenshot(perm)
|
|
275
|
+
return ToolResult(True, out)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
@register(
|
|
279
|
+
"desktop_screenshot",
|
|
280
|
+
"Capture a screenshot (whole desktop, a monitor, or a region) to a PNG "
|
|
281
|
+
"file; optionally OCR its text.",
|
|
282
|
+
{
|
|
283
|
+
"monitor": "(optional) 1-based monitor number",
|
|
284
|
+
"region": "(optional) [left, top, width, height]",
|
|
285
|
+
"ocr": "(optional) true to also extract text via OCR",
|
|
286
|
+
},
|
|
287
|
+
mutates=False,
|
|
288
|
+
group="desktop",
|
|
289
|
+
)
|
|
290
|
+
def _desktop_screenshot(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
291
|
+
engine = _engine(perm)
|
|
292
|
+
_gate_control(perm, "take a screenshot")
|
|
293
|
+
controller = engine.controller
|
|
294
|
+
|
|
295
|
+
region = None
|
|
296
|
+
raw_region = args.get("region")
|
|
297
|
+
if raw_region is not None:
|
|
298
|
+
try:
|
|
299
|
+
left, top, width, height = (int(v) for v in raw_region)
|
|
300
|
+
region = (left, top, width, height)
|
|
301
|
+
except (TypeError, ValueError):
|
|
302
|
+
return ToolResult(False, "region must be [left, top, width, height] integers.")
|
|
303
|
+
monitor = None
|
|
304
|
+
if args.get("monitor") is not None:
|
|
305
|
+
try:
|
|
306
|
+
monitor = int(args["monitor"])
|
|
307
|
+
except (TypeError, ValueError):
|
|
308
|
+
return ToolResult(False, "monitor must be an integer (1-based).")
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
path = controller.screenshot(region=region, monitor=monitor)
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
return ToolResult(False, f"Screenshot failed: {exc}")
|
|
314
|
+
output = f"Screenshot saved: {path}"
|
|
315
|
+
if str(args.get("ocr", "")).lower() in ("true", "1", "yes"):
|
|
316
|
+
output += "\n[OCR]\n" + controller.vision.ocr_screenshot(path)
|
|
317
|
+
_queue_screenshot(perm, str(path))
|
|
318
|
+
return ToolResult(True, output)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@register(
|
|
322
|
+
"desktop_windows",
|
|
323
|
+
"List all open windows with their titles, positions, and sizes.",
|
|
324
|
+
{},
|
|
325
|
+
mutates=False,
|
|
326
|
+
group="desktop",
|
|
327
|
+
)
|
|
328
|
+
def _desktop_windows(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
329
|
+
engine = _engine(perm)
|
|
330
|
+
_gate_control(perm, "list open windows")
|
|
331
|
+
try:
|
|
332
|
+
return ToolResult(True, engine.controller.list_windows())
|
|
333
|
+
except Exception as exc:
|
|
334
|
+
return ToolResult(False, f"Could not list windows: {exc}")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@register(
|
|
338
|
+
"desktop_screen_info",
|
|
339
|
+
"Report screen resolution and multi-monitor layout.",
|
|
340
|
+
{},
|
|
341
|
+
mutates=False,
|
|
342
|
+
group="desktop",
|
|
343
|
+
)
|
|
344
|
+
def _desktop_screen_info(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
345
|
+
engine = _engine(perm)
|
|
346
|
+
_gate_control(perm, "read screen info")
|
|
347
|
+
try:
|
|
348
|
+
return ToolResult(True, engine.controller.screen_info())
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
return ToolResult(False, f"Could not read screen info: {exc}")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --- screenshot hand-off to the agent loop -----------------------------------
|
|
354
|
+
def _queue_screenshot(perm: "PermissionManager", path: str | None = None) -> None:
|
|
355
|
+
"""Queue a screenshot for image-capable providers (best-effort).
|
|
356
|
+
|
|
357
|
+
The agent loop attaches the encoded image to the next tool-results message
|
|
358
|
+
when the active provider supports vision. Failure here never fails a tool.
|
|
359
|
+
"""
|
|
360
|
+
desktop = perm.desktop
|
|
361
|
+
if desktop is None:
|
|
362
|
+
return
|
|
363
|
+
try:
|
|
364
|
+
from ..computer import screen
|
|
365
|
+
|
|
366
|
+
if path is None:
|
|
367
|
+
path = str(screen.capture())
|
|
368
|
+
desktop.pending_images.append(screen.encode_png_base64(path))
|
|
369
|
+
del desktop.pending_images[:-1] # keep only the latest frame
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"""Filesystem tools: read, write, list, delete — plus the project indexer.
|
|
2
|
+
|
|
3
|
+
Multi-file editing is simply several ``write_file``/``edit_file`` calls in one
|
|
4
|
+
agent turn; each one passes the same permission gate. The indexer produces a
|
|
5
|
+
compact tree of the workspace so the model can orient itself without reading
|
|
6
|
+
every file.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import shutil
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .base import ToolResult, int_arg, register
|
|
16
|
+
from .permissions import CATEGORY_DELETE
|
|
17
|
+
from .textio import TextFile, read_text_file, write_text_file
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from .permissions import PermissionManager
|
|
21
|
+
|
|
22
|
+
# Read caps: a tool call must never dump a huge binary/log into the context.
|
|
23
|
+
_MAX_READ_BYTES = 256 * 1024
|
|
24
|
+
_MAX_INDEX_ENTRIES = 400
|
|
25
|
+
|
|
26
|
+
# Directories that never belong in a project index.
|
|
27
|
+
_INDEX_SKIP = {
|
|
28
|
+
".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv",
|
|
29
|
+
"dist", "build", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@register(
|
|
34
|
+
"read_file",
|
|
35
|
+
"Read a text file (optionally a line range).",
|
|
36
|
+
{
|
|
37
|
+
"path": "file path",
|
|
38
|
+
"start_line": "(optional) first line, 1-based",
|
|
39
|
+
"end_line": "(optional) last line, inclusive",
|
|
40
|
+
},
|
|
41
|
+
mutates=False,
|
|
42
|
+
types={"start_line": "integer", "end_line": "integer"},
|
|
43
|
+
)
|
|
44
|
+
def _read_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
45
|
+
path = perm.resolve(args["path"])
|
|
46
|
+
perm.check_read(path)
|
|
47
|
+
if not path.is_file():
|
|
48
|
+
return ToolResult(False, f"File not found: {path}")
|
|
49
|
+
if path.stat().st_size > _MAX_READ_BYTES and "start_line" not in args:
|
|
50
|
+
return ToolResult(
|
|
51
|
+
False,
|
|
52
|
+
f"File is large ({path.stat().st_size} bytes). "
|
|
53
|
+
"Read it in ranges with start_line/end_line.",
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
57
|
+
except OSError as exc:
|
|
58
|
+
return ToolResult(False, f"Could not read {path}: {exc}")
|
|
59
|
+
|
|
60
|
+
lines = text.splitlines()
|
|
61
|
+
start = int_arg(args, "start_line", 1, 1, 10_000_000)
|
|
62
|
+
end = min(len(lines), int_arg(args, "end_line", len(lines), 0, 10_000_000))
|
|
63
|
+
numbered = [f"{i}\t{lines[i - 1]}" for i in range(start, end + 1)]
|
|
64
|
+
header = f"{path} ({len(lines)} lines, showing {start}-{end})"
|
|
65
|
+
return ToolResult(True, header + "\n" + "\n".join(numbered))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@register(
|
|
69
|
+
"write_file",
|
|
70
|
+
"Create or overwrite a file with the given content.",
|
|
71
|
+
{"path": "file path", "content": "full new file content"},
|
|
72
|
+
mutates=True,
|
|
73
|
+
)
|
|
74
|
+
def _write_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
75
|
+
path = perm.resolve(args["path"])
|
|
76
|
+
perm.check_write(path)
|
|
77
|
+
content = str(args["content"])
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
path.write_text(content, encoding="utf-8")
|
|
82
|
+
except OSError as exc:
|
|
83
|
+
return ToolResult(False, f"Could not write {path}: {exc}")
|
|
84
|
+
|
|
85
|
+
# VERIFICATION: Read back to confirm write succeeded
|
|
86
|
+
try:
|
|
87
|
+
written = path.read_text(encoding="utf-8", errors="replace")
|
|
88
|
+
if written != content:
|
|
89
|
+
return ToolResult(
|
|
90
|
+
False,
|
|
91
|
+
f"Verification failed: {path} was written but content doesn't match. "
|
|
92
|
+
f"Expected {len(content)} chars, read back {len(written)} chars."
|
|
93
|
+
)
|
|
94
|
+
except OSError as exc:
|
|
95
|
+
return ToolResult(
|
|
96
|
+
False,
|
|
97
|
+
f"Write completed but verification failed: could not read {path}: {exc}"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
return ToolResult(True, f"Wrote {len(content)} chars to {path} (verified)")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@register(
|
|
104
|
+
"delete_file",
|
|
105
|
+
"Delete a single file (never a directory).",
|
|
106
|
+
{"path": "file path"},
|
|
107
|
+
mutates=True,
|
|
108
|
+
)
|
|
109
|
+
def _delete_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
110
|
+
path = perm.resolve(args["path"])
|
|
111
|
+
perm.check_write(path)
|
|
112
|
+
if path.is_dir():
|
|
113
|
+
return ToolResult(False, f"Refusing to delete a directory: {path}")
|
|
114
|
+
if not path.exists():
|
|
115
|
+
return ToolResult(False, f"File not found: {path}")
|
|
116
|
+
perm.confirm_action(CATEGORY_DELETE, str(path))
|
|
117
|
+
try:
|
|
118
|
+
path.unlink()
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
return ToolResult(False, f"Could not delete {path}: {exc}")
|
|
121
|
+
|
|
122
|
+
# VERIFICATION: Confirm file no longer exists
|
|
123
|
+
if path.exists():
|
|
124
|
+
return ToolResult(
|
|
125
|
+
False,
|
|
126
|
+
f"Delete command completed but {path} still exists. Possible permission or filesystem issue."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return ToolResult(True, f"Deleted {path} (verified)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@register(
|
|
133
|
+
"list_dir",
|
|
134
|
+
"List the entries of a directory.",
|
|
135
|
+
{"path": "(optional) directory path, defaults to the workspace root"},
|
|
136
|
+
mutates=False,
|
|
137
|
+
)
|
|
138
|
+
def _list_dir(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
139
|
+
path = perm.resolve(args.get("path") or ".")
|
|
140
|
+
perm.check_read(path)
|
|
141
|
+
if not path.is_dir():
|
|
142
|
+
return ToolResult(False, f"Not a directory: {path}")
|
|
143
|
+
try:
|
|
144
|
+
entries = sorted(path.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
return ToolResult(False, f"Could not list {path}: {exc}")
|
|
147
|
+
lines = [f"{'d' if e.is_dir() else 'f'} {e.name}" for e in entries]
|
|
148
|
+
return ToolResult(True, f"{path}\n" + ("\n".join(lines) or "(empty)"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@register(
|
|
152
|
+
"append_file",
|
|
153
|
+
"Append content to the end of a file (created if missing).",
|
|
154
|
+
{"path": "file path", "content": "text to append"},
|
|
155
|
+
mutates=True,
|
|
156
|
+
)
|
|
157
|
+
def _append_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
158
|
+
path = perm.resolve(args["path"])
|
|
159
|
+
perm.check_write(path)
|
|
160
|
+
content = str(args["content"])
|
|
161
|
+
if not content:
|
|
162
|
+
return ToolResult(False, "content is empty; nothing to append.")
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if path.is_file():
|
|
166
|
+
tf = read_text_file(path) # preserve encoding + line endings
|
|
167
|
+
tf.text += content.replace("\r\n", "\n")
|
|
168
|
+
else:
|
|
169
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
170
|
+
tf = TextFile(text=content.replace("\r\n", "\n"), encoding="utf-8", newline="\n")
|
|
171
|
+
write_text_file(path, tf)
|
|
172
|
+
except OSError as exc:
|
|
173
|
+
return ToolResult(False, f"Could not append to {path}: {exc}")
|
|
174
|
+
|
|
175
|
+
# VERIFICATION: the appended text must be at the end of the file
|
|
176
|
+
try:
|
|
177
|
+
if not read_text_file(path).text.endswith(content.replace("\r\n", "\n")):
|
|
178
|
+
return ToolResult(
|
|
179
|
+
False,
|
|
180
|
+
f"Append completed but verification failed: {path} does not end "
|
|
181
|
+
"with the appended text.",
|
|
182
|
+
)
|
|
183
|
+
except OSError as exc:
|
|
184
|
+
return ToolResult(
|
|
185
|
+
False, f"Append completed but verification failed: could not read {path}: {exc}"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
return ToolResult(True, f"Appended {len(content)} chars to {path} (verified)")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@register(
|
|
192
|
+
"create_directory",
|
|
193
|
+
"Create a directory (parents included).",
|
|
194
|
+
{"path": "directory path"},
|
|
195
|
+
mutates=True,
|
|
196
|
+
)
|
|
197
|
+
def _create_directory(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
198
|
+
path = perm.resolve(args["path"])
|
|
199
|
+
perm.check_write(path)
|
|
200
|
+
if path.is_file():
|
|
201
|
+
return ToolResult(False, f"A file already exists at {path}.")
|
|
202
|
+
existed = path.is_dir()
|
|
203
|
+
try:
|
|
204
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
except OSError as exc:
|
|
206
|
+
return ToolResult(False, f"Could not create directory {path}: {exc}")
|
|
207
|
+
if not path.is_dir():
|
|
208
|
+
return ToolResult(False, f"mkdir completed but {path} does not exist.")
|
|
209
|
+
return ToolResult(True, f"Directory {'already existed' if existed else 'created'}: {path}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@register(
|
|
213
|
+
"rename_file",
|
|
214
|
+
"Rename a file within its directory (use move_file to change directories).",
|
|
215
|
+
{"path": "current file path", "new_name": "new file name (no directories)"},
|
|
216
|
+
mutates=True,
|
|
217
|
+
)
|
|
218
|
+
def _rename_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
219
|
+
path = perm.resolve(args["path"])
|
|
220
|
+
new_name = str(args["new_name"]).strip()
|
|
221
|
+
if not new_name or any(sep in new_name for sep in ("/", "\\")):
|
|
222
|
+
return ToolResult(
|
|
223
|
+
False, "new_name must be a bare file name — use move_file to change directories."
|
|
224
|
+
)
|
|
225
|
+
target = path.with_name(new_name)
|
|
226
|
+
perm.check_write(path)
|
|
227
|
+
perm.check_write(target)
|
|
228
|
+
if not path.exists():
|
|
229
|
+
return ToolResult(False, f"File not found: {path}")
|
|
230
|
+
if target.exists():
|
|
231
|
+
return ToolResult(False, f"Refusing to overwrite existing {target}.")
|
|
232
|
+
try:
|
|
233
|
+
path.rename(target)
|
|
234
|
+
except OSError as exc:
|
|
235
|
+
return ToolResult(False, f"Could not rename {path}: {exc}")
|
|
236
|
+
if not target.exists() or path.exists():
|
|
237
|
+
return ToolResult(False, f"Rename completed but verification failed for {target}.")
|
|
238
|
+
return ToolResult(True, f"Renamed {path} -> {target} (verified)")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
@register(
|
|
242
|
+
"move_file",
|
|
243
|
+
"Move a file to another path (directories created as needed).",
|
|
244
|
+
{"path": "current file path", "destination": "new file path"},
|
|
245
|
+
mutates=True,
|
|
246
|
+
)
|
|
247
|
+
def _move_file(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
248
|
+
path = perm.resolve(args["path"])
|
|
249
|
+
target = perm.resolve(args["destination"])
|
|
250
|
+
perm.check_write(path)
|
|
251
|
+
perm.check_write(target) # outside-workspace destinations hit the gate
|
|
252
|
+
if not path.exists():
|
|
253
|
+
return ToolResult(False, f"File not found: {path}")
|
|
254
|
+
if path.is_dir():
|
|
255
|
+
return ToolResult(False, f"Refusing to move a directory: {path}")
|
|
256
|
+
if target.is_dir():
|
|
257
|
+
target = target / path.name
|
|
258
|
+
if target.exists():
|
|
259
|
+
return ToolResult(False, f"Refusing to overwrite existing {target}.")
|
|
260
|
+
try:
|
|
261
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
262
|
+
shutil.move(str(path), str(target)) # handles cross-drive on Windows
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
return ToolResult(False, f"Could not move {path}: {exc}")
|
|
265
|
+
if not target.exists() or path.exists():
|
|
266
|
+
return ToolResult(False, f"Move completed but verification failed for {target}.")
|
|
267
|
+
return ToolResult(True, f"Moved {path} -> {target} (verified)")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _walk_index(root: Path, prefix: str, lines: list[str]) -> None:
|
|
271
|
+
"""Depth-first tree walk, capped at _MAX_INDEX_ENTRIES lines."""
|
|
272
|
+
if len(lines) >= _MAX_INDEX_ENTRIES:
|
|
273
|
+
return
|
|
274
|
+
try:
|
|
275
|
+
entries = sorted(root.iterdir(), key=lambda p: (p.is_file(), p.name.lower()))
|
|
276
|
+
except OSError:
|
|
277
|
+
return
|
|
278
|
+
for entry in entries:
|
|
279
|
+
if len(lines) >= _MAX_INDEX_ENTRIES:
|
|
280
|
+
lines.append(f"{prefix}... (index capped at {_MAX_INDEX_ENTRIES} entries)")
|
|
281
|
+
return
|
|
282
|
+
if entry.name in _INDEX_SKIP or entry.name.startswith("."):
|
|
283
|
+
continue
|
|
284
|
+
if entry.is_dir():
|
|
285
|
+
lines.append(f"{prefix}{entry.name}/")
|
|
286
|
+
_walk_index(entry, prefix + " ", lines)
|
|
287
|
+
else:
|
|
288
|
+
try:
|
|
289
|
+
size = entry.stat().st_size
|
|
290
|
+
except OSError:
|
|
291
|
+
size = 0
|
|
292
|
+
lines.append(f"{prefix}{entry.name} ({size} B)")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def build_index(perm: "PermissionManager") -> str:
|
|
296
|
+
"""Compact workspace tree (shared by the tool and the /index command)."""
|
|
297
|
+
lines: list[str] = [f"{perm.workspace}/"]
|
|
298
|
+
_walk_index(perm.workspace, " ", lines)
|
|
299
|
+
return "\n".join(lines)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@register(
|
|
303
|
+
"project_index",
|
|
304
|
+
"Get a compact tree of the whole workspace (files, sizes).",
|
|
305
|
+
{},
|
|
306
|
+
mutates=False,
|
|
307
|
+
)
|
|
308
|
+
def _project_index(perm: "PermissionManager", args: dict[str, Any]) -> ToolResult:
|
|
309
|
+
return ToolResult(True, build_index(perm))
|