devpilot-agentic-cli 1.0.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.
agent/tools/fs.py ADDED
@@ -0,0 +1,411 @@
1
+ """
2
+ agent/tools/fs.py
3
+ ─────────────────
4
+ Filesystem tools — read_file, write_file, list_files.
5
+
6
+ Improvements over original:
7
+ - ReadFileTool: records reads into RepoContext for awareness tracking
8
+ - WriteFileTool: computes and displays a syntax-highlighted unified diff
9
+ before writing; records writes into RepoContext
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import ast
15
+ import difflib
16
+ import os
17
+ import subprocess
18
+ import sys
19
+ import tempfile
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ from agent.tools.base import BaseTool, ToolResult, ToolSchema
24
+
25
+ if TYPE_CHECKING:
26
+ from agent.config import Config
27
+ from agent.context import RepoContext
28
+
29
+
30
+ def _safe_path(workdir: str, path: str) -> Path:
31
+ """
32
+ Resolve path relative to workdir and ensure it doesn't escape.
33
+ Raises ValueError if the resolved path is outside workdir.
34
+ """
35
+ workdir_path = Path(workdir).resolve()
36
+ candidate = Path(path) if Path(path).is_absolute() else workdir_path / path
37
+ resolved = candidate.resolve()
38
+ if not str(resolved).startswith(str(workdir_path)):
39
+ raise ValueError(f"Path '{path}' escapes the working directory.")
40
+ return resolved
41
+
42
+
43
+ def _unified_diff(old_text: str, new_text: str, path: str) -> str:
44
+ """Return a unified diff string between old and new content."""
45
+ old_lines = old_text.splitlines(keepends=True)
46
+ new_lines = new_text.splitlines(keepends=True)
47
+ diff = difflib.unified_diff(
48
+ old_lines,
49
+ new_lines,
50
+ fromfile=f"a/{path}",
51
+ tofile=f"b/{path}",
52
+ lineterm="",
53
+ )
54
+ return "".join(diff)
55
+
56
+
57
+ def _lint_content(path: str, content: str) -> str | None:
58
+ """
59
+ Perform pre-flight syntax checks on code content before writing.
60
+ Returns an error message if linting fails, or None if successful.
61
+ """
62
+ suffix = Path(path).suffix.lower()
63
+ if suffix == ".py":
64
+ try:
65
+ ast.parse(content)
66
+ return None
67
+ except SyntaxError as e:
68
+ error_line = e.text.rstrip() if e.text else ""
69
+ marker = " " * ((e.offset or 1) - 1) + "^" if e.offset else ""
70
+ return f"SyntaxError in {path} at line {e.lineno}:\n{error_line}\n{marker}\n{e.msg}"
71
+ except Exception as e:
72
+ return f"Error parsing {path}: {e}"
73
+
74
+ elif suffix in {".js", ".jsx", ".ts", ".tsx"}:
75
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, mode="w", encoding="utf-8") as f:
76
+ f.write(content)
77
+ tmp_path = f.name
78
+
79
+ try:
80
+ if suffix in {".js", ".jsx"}:
81
+ cmd = ["node", "--check", tmp_path]
82
+ else:
83
+ cmd = ["npx.cmd" if sys.platform == "win32" else "npx", "tsc", "--noEmit", tmp_path]
84
+
85
+ result = subprocess.run(cmd, capture_output=True, text=True)
86
+ if result.returncode != 0:
87
+ error_output = result.stderr if result.stderr else result.stdout
88
+ error_output = error_output.replace(tmp_path, path)
89
+ return f"Syntax Error in {path}:\n{error_output}"
90
+ return None
91
+ except FileNotFoundError:
92
+ # node or npx not installed, skip gracefully
93
+ return None
94
+ except Exception as e:
95
+ return f"Error linting {path}: {e}"
96
+ finally:
97
+ if os.path.exists(tmp_path):
98
+ try:
99
+ os.remove(tmp_path)
100
+ except OSError:
101
+ pass
102
+
103
+ return None
104
+
105
+
106
+ class ReadFileTool(BaseTool):
107
+ """Read any file inside the working directory."""
108
+
109
+ def __init__(self, config: "Config", context: "RepoContext | None" = None) -> None:
110
+ self._workdir = config.workdir
111
+ self._context = context
112
+
113
+ @property
114
+ def schema(self) -> ToolSchema:
115
+ return ToolSchema(
116
+ name="read_file",
117
+ description=(
118
+ "Read the contents of a file. Path is relative to the working directory. "
119
+ "Always read a file before modifying it. "
120
+ "Check the session context — if the file is already listed as read and not "
121
+ "marked stale, you can rely on your memory of it."
122
+ ),
123
+ parameters={
124
+ "type": "object",
125
+ "properties": {
126
+ "path": {
127
+ "type": "string",
128
+ "description": "Relative path to the file (e.g. 'src/main.py').",
129
+ },
130
+ "start_line": {
131
+ "type": "integer",
132
+ "description": "Optional 1-based start line for partial reads.",
133
+ },
134
+ "end_line": {
135
+ "type": "integer",
136
+ "description": "Optional 1-based end line for partial reads.",
137
+ },
138
+ },
139
+ "required": ["path"],
140
+ },
141
+ required=["path"],
142
+ sprint="Sprint 1",
143
+ )
144
+
145
+ async def execute( # type: ignore[override]
146
+ self,
147
+ path: str,
148
+ start_line: int | None = None,
149
+ end_line: int | None = None,
150
+ ) -> ToolResult:
151
+ try:
152
+ safe_p = _safe_path(self._workdir, path)
153
+ if not safe_p.exists():
154
+ return ToolResult(f"Error: File not found: {path}", is_error=True)
155
+ if not safe_p.is_file():
156
+ return ToolResult(f"Error: Path is not a file: {path}", is_error=True)
157
+
158
+ content = safe_p.read_text(encoding="utf-8", errors="replace")
159
+
160
+ # Record full content into context regardless of slice
161
+ if self._context is not None:
162
+ self._context.record_read(path, content)
163
+
164
+ lines = content.splitlines()
165
+ start_idx = max(0, start_line - 1) if start_line else 0
166
+ end_idx = min(len(lines), end_line) if end_line else len(lines)
167
+ sliced = lines[start_idx:end_idx]
168
+
169
+ numbered = "\n".join(
170
+ f"{i + start_idx + 1:>4}: {line}" for i, line in enumerate(sliced)
171
+ )
172
+ return ToolResult(
173
+ f"Contents of {path} (lines {start_idx + 1}-{end_idx}):\n\n{numbered}",
174
+ is_error=False,
175
+ )
176
+ except Exception as e:
177
+ return ToolResult(f"Error reading {path}: {e}", is_error=True)
178
+
179
+
180
+ class WriteFileTool(BaseTool):
181
+ """Write or overwrite a file. Renders a diff and asks permission before writing."""
182
+
183
+ def __init__(self, config: "Config", context: "RepoContext | None" = None) -> None:
184
+ self._config = config
185
+ self._context = context
186
+
187
+ @property
188
+ def schema(self) -> ToolSchema:
189
+ return ToolSchema(
190
+ name="write_file",
191
+ description=(
192
+ "Write content to a file, creating it if it doesn't exist or "
193
+ "overwriting it if it does. Always shows a diff before writing. "
194
+ "CRITICAL: You MUST provide the ENTIRE full file content. "
195
+ "NEVER use placeholders like '... existing code ...' or write short stubs. "
196
+ "If modifying an existing file, you must include all unmodified lines exactly as they were."
197
+ ),
198
+ parameters={
199
+ "type": "object",
200
+ "properties": {
201
+ "path": {
202
+ "type": "string",
203
+ "description": "Relative path to the file.",
204
+ },
205
+ "content": {
206
+ "type": "string",
207
+ "description": "Full new content of the file.",
208
+ },
209
+ },
210
+ "required": ["path", "content"],
211
+ },
212
+ required=["path", "content"],
213
+ is_destructive=True,
214
+ sprint="Sprint 1",
215
+ )
216
+
217
+ async def execute(self, path: str, content: str) -> ToolResult: # type: ignore[override]
218
+ try:
219
+ safe_p = _safe_path(self._config.workdir, path)
220
+
221
+ lint_error = _lint_content(path, content)
222
+ if lint_error:
223
+ return ToolResult(f"Pre-Flight Linting Failed:\n{lint_error}", is_error=True)
224
+
225
+ # Compute and display diff before writing
226
+ if safe_p.exists() and safe_p.is_file():
227
+ old_content = safe_p.read_text(encoding="utf-8", errors="replace")
228
+ diff = _unified_diff(old_content, content, path)
229
+ if diff:
230
+ from agent.ui import UI
231
+ UI.print_diff(path, diff)
232
+ else:
233
+ from agent.ui import UI
234
+ UI.print_info(f"No changes to {path} (content identical).")
235
+ return ToolResult(f"No changes written — content of {path} is identical.", is_error=False)
236
+ else:
237
+ # New file — show full content as a creation diff
238
+ from agent.ui import UI
239
+ diff = _unified_diff("", content, path)
240
+ UI.print_diff(path, diff, is_new=True)
241
+
242
+ safe_p.parent.mkdir(parents=True, exist_ok=True)
243
+ safe_p.write_text(content, encoding="utf-8")
244
+
245
+ # Record write in context
246
+ if self._context is not None:
247
+ self._context.record_write(path, content)
248
+
249
+ return ToolResult(f"✓ Written {len(content)} characters to {path}", is_error=False)
250
+ except Exception as e:
251
+ return ToolResult(f"Error writing {path}: {e}", is_error=True)
252
+
253
+
254
+ class EditFileTool(BaseTool):
255
+ """Surgically edit a file by replacing a specific block of text."""
256
+
257
+ def __init__(self, config: "Config", context: "RepoContext | None" = None) -> None:
258
+ self._config = config
259
+ self._context = context
260
+
261
+ @property
262
+ def schema(self) -> ToolSchema:
263
+ return ToolSchema(
264
+ name="edit_file",
265
+ description=(
266
+ "Edit an existing file by replacing a specific block of text. "
267
+ "This is much faster and cheaper than write_file for large files. "
268
+ "You must provide the exact old_content you wish to replace. "
269
+ "The old_content must be unique within the file."
270
+ ),
271
+ parameters={
272
+ "type": "object",
273
+ "properties": {
274
+ "path": {
275
+ "type": "string",
276
+ "description": "Relative path to the file.",
277
+ },
278
+ "old_content": {
279
+ "type": "string",
280
+ "description": "The exact existing text block to be replaced. Must match the file exactly, including whitespace.",
281
+ },
282
+ "new_content": {
283
+ "type": "string",
284
+ "description": "The new text block that will replace old_content.",
285
+ },
286
+ },
287
+ "required": ["path", "old_content", "new_content"],
288
+ },
289
+ required=["path", "old_content", "new_content"],
290
+ is_destructive=True,
291
+ sprint="Sprint 2",
292
+ )
293
+
294
+ async def execute(self, path: str, old_content: str, new_content: str) -> ToolResult: # type: ignore[override]
295
+ try:
296
+ safe_p = _safe_path(self._config.workdir, path)
297
+ if not safe_p.exists() or not safe_p.is_file():
298
+ return ToolResult(f"Error: File not found or is not a file: {path}", is_error=True)
299
+
300
+ file_content = safe_p.read_text(encoding="utf-8", errors="replace")
301
+
302
+ # Validation as requested in Phase 1
303
+ occurrences = file_content.count(old_content)
304
+ if occurrences == 0:
305
+ return ToolResult(
306
+ f"Error: The provided old_content was not found in the file. "
307
+ "Make sure you have an exact match including leading spaces and newlines.",
308
+ is_error=True
309
+ )
310
+ elif occurrences > 1:
311
+ return ToolResult(
312
+ f"Error: The provided old_content appears {occurrences} times in the file. "
313
+ "Include more surrounding context to make the block unique.",
314
+ is_error=True
315
+ )
316
+
317
+ # Perform surgical replacement
318
+ updated_content = file_content.replace(old_content, new_content)
319
+
320
+ lint_error = _lint_content(path, updated_content)
321
+ if lint_error:
322
+ return ToolResult(f"Pre-Flight Linting Failed:\n{lint_error}", is_error=True)
323
+
324
+ # Compute and display diff before writing
325
+ diff = _unified_diff(file_content, updated_content, path)
326
+ if diff:
327
+ from agent.ui import UI
328
+ UI.print_diff(path, diff)
329
+ else:
330
+ from agent.ui import UI
331
+ UI.print_info(f"No changes to {path} (content identical).")
332
+ return ToolResult(f"No changes written — content of {path} is identical.", is_error=False)
333
+
334
+ # Write the file
335
+ safe_p.write_text(updated_content, encoding="utf-8")
336
+
337
+ # Record write in context
338
+ if self._context is not None:
339
+ self._context.record_write(path, updated_content)
340
+
341
+ return ToolResult(f"✓ Edited {path} successfully.", is_error=False)
342
+ except Exception as e:
343
+ return ToolResult(f"Error editing {path}: {e}", is_error=True)
344
+
345
+
346
+
347
+ class ListFilesTool(BaseTool):
348
+ """List files and directories in the working directory."""
349
+
350
+ def __init__(self, config: "Config", context: "RepoContext | None" = None) -> None:
351
+ self._workdir = config.workdir
352
+
353
+ @property
354
+ def schema(self) -> ToolSchema:
355
+ return ToolSchema(
356
+ name="list_files",
357
+ description=(
358
+ "List files and directories. Use before read or write to "
359
+ "confirm paths. Defaults to the working directory root."
360
+ ),
361
+ parameters={
362
+ "type": "object",
363
+ "properties": {
364
+ "path": {
365
+ "type": "string",
366
+ "description": "Relative path to list (default: '.').",
367
+ "default": ".",
368
+ },
369
+ "recursive": {
370
+ "type": "boolean",
371
+ "description": "If true, list all files recursively.",
372
+ "default": False,
373
+ },
374
+ },
375
+ "required": [],
376
+ },
377
+ sprint="Sprint 1",
378
+ )
379
+
380
+ async def execute(self, path: str = ".", recursive: bool = False) -> ToolResult: # type: ignore[override]
381
+ try:
382
+ safe_p = _safe_path(self._workdir, path)
383
+ if not safe_p.exists():
384
+ return ToolResult(f"Error: Path not found: {path}", is_error=True)
385
+ if not safe_p.is_dir():
386
+ return ToolResult(f"Error: Not a directory: {path}", is_error=True)
387
+
388
+ entries: list[str] = []
389
+
390
+ def _scan(directory: Path, prefix: str = "") -> None:
391
+ for entry in sorted(directory.iterdir()):
392
+ if entry.name.startswith(".") and entry.name != ".env":
393
+ continue
394
+ rel_path = prefix + entry.name
395
+ if entry.is_dir():
396
+ entries.append(f" 📁 {rel_path}/")
397
+ if recursive:
398
+ _scan(entry, prefix=rel_path + "/")
399
+ else:
400
+ size = entry.stat().st_size
401
+ entries.append(f" 📄 {rel_path} ({size:,} bytes)")
402
+
403
+ _scan(safe_p)
404
+
405
+ if not entries:
406
+ return ToolResult(f"Directory {path} is empty.", is_error=False)
407
+
408
+ return ToolResult(f"Contents of {path}:\n" + "\n".join(entries), is_error=False)
409
+
410
+ except Exception as e:
411
+ return ToolResult(f"Error listing {path}: {e}", is_error=True)
agent/tools/git_ops.py ADDED
@@ -0,0 +1,145 @@
1
+ """
2
+ agent/tools/git_ops.py
3
+ ──────────────────────
4
+ Git operations tools using GitPython.
5
+ Separated into GitStatusTool and GitCommitTool for surgical operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from agent.tools.base import BaseTool, ToolResult, ToolSchema
13
+
14
+ if TYPE_CHECKING:
15
+ from agent.config import Config
16
+
17
+
18
+ class GitStatusTool(BaseTool):
19
+ """Check git status and uncommitted changes."""
20
+
21
+ def __init__(self, config: "Config") -> None:
22
+ self._config = config
23
+
24
+ @property
25
+ def schema(self) -> ToolSchema:
26
+ return ToolSchema(
27
+ name="git_status",
28
+ description="View the current git branch, staged files, modified files, untracked files, and the current working tree diff.",
29
+ parameters={
30
+ "type": "object",
31
+ "properties": {},
32
+ },
33
+ sprint="Sprint 2",
34
+ )
35
+
36
+ async def execute(self) -> ToolResult: # type: ignore[override]
37
+ try:
38
+ import git # type: ignore[import]
39
+ except ImportError:
40
+ return ToolResult("Error: GitPython not installed. Run: pip install gitpython", is_error=True)
41
+
42
+ workdir = self._config.workdir
43
+
44
+ try:
45
+ repo = git.Repo(workdir, search_parent_directories=True)
46
+ changed = [item.a_path for item in repo.index.diff(None) if item.a_path is not None]
47
+ staged = [item.a_path for item in repo.index.diff("HEAD") if item.a_path is not None] if repo.head.is_valid() else []
48
+ untracked = repo.untracked_files
49
+ branch_name = repo.active_branch.name if not repo.head.is_detached else "DETACHED HEAD"
50
+
51
+ diff = repo.git.diff()
52
+
53
+ lines = [
54
+ f"Branch: {branch_name}",
55
+ f"Staged ({len(staged)}): {', '.join(staged) or 'none'}",
56
+ f"Modified ({len(changed)}): {', '.join(changed) or 'none'}",
57
+ f"Untracked ({len(untracked)}): {', '.join(untracked[:10]) or 'none'}",
58
+ "\n--- Unstaged Diff ---\n" + (diff[:8000] if diff else "No unstaged changes.")
59
+ ]
60
+ return ToolResult("\n".join(lines), is_error=False)
61
+
62
+ except git.InvalidGitRepositoryError:
63
+ return ToolResult(f"Error: {workdir} is not a git repository.", is_error=True)
64
+ except Exception as e:
65
+ return ToolResult(f"Git error: {e}", is_error=True)
66
+
67
+
68
+ class GitCommitTool(BaseTool):
69
+ """Stage specific files and commit."""
70
+
71
+ def __init__(self, config: "Config") -> None:
72
+ self._config = config
73
+
74
+ @property
75
+ def schema(self) -> ToolSchema:
76
+ return ToolSchema(
77
+ name="git_commit",
78
+ description=(
79
+ "Stage specific files and commit them to the repository. "
80
+ "You must explicitly provide the paths to stage. "
81
+ "Returns the diff of what was actually committed."
82
+ ),
83
+ parameters={
84
+ "type": "object",
85
+ "properties": {
86
+ "paths": {
87
+ "type": "array",
88
+ "items": {"type": "string"},
89
+ "description": "List of file paths to stage (e.g., ['agent/loop.py', 'README.md']).",
90
+ },
91
+ "message": {
92
+ "type": "string",
93
+ "description": "The commit message.",
94
+ },
95
+ },
96
+ "required": ["paths", "message"],
97
+ },
98
+ required=["paths", "message"],
99
+ is_destructive=True,
100
+ sprint="Sprint 2",
101
+ )
102
+
103
+ async def execute(self, paths: list[str], message: str) -> ToolResult: # type: ignore[override]
104
+ try:
105
+ import git # type: ignore[import]
106
+ except ImportError:
107
+ return ToolResult("Error: GitPython not installed.", is_error=True)
108
+
109
+ workdir = self._config.workdir
110
+
111
+ try:
112
+ repo = git.Repo(workdir, search_parent_directories=True)
113
+
114
+ if not paths:
115
+ return ToolResult("Error: 'paths' list cannot be empty. Specify which files to commit.", is_error=True)
116
+ if not message:
117
+ return ToolResult("Error: 'message' is required for commit.", is_error=True)
118
+
119
+ # Stage the specific files
120
+ repo.index.add(paths)
121
+
122
+ # Capture the staged diff before committing
123
+ # If repo has no commits yet, diff against empty tree
124
+ try:
125
+ if not repo.head.is_valid():
126
+ # Initial commit staging diff
127
+ staged_diff = "Initial commit: All staged files."
128
+ else:
129
+ staged_diff = repo.git.diff("--staged")
130
+ except Exception:
131
+ staged_diff = "(Could not compute staged diff)"
132
+
133
+ # Commit
134
+ commit = repo.index.commit(message)
135
+
136
+ res = [
137
+ f"✓ Committed: {commit.hexsha[:8]} — {message}",
138
+ f"\n--- Staged Diff ---\n{staged_diff[:8000]}"
139
+ ]
140
+ return ToolResult("\n".join(res), is_error=False)
141
+
142
+ except git.InvalidGitRepositoryError:
143
+ return ToolResult(f"Error: {workdir} is not a git repository.", is_error=True)
144
+ except Exception as e:
145
+ return ToolResult(f"Git error: {e}", is_error=True)