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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. 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))