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/__init__.py +1 -0
- agent/a2a_client.py +94 -0
- agent/a2a_server.py +148 -0
- agent/cli.py +233 -0
- agent/config.py +232 -0
- agent/context.py +182 -0
- agent/history.py +172 -0
- agent/loop.py +102 -0
- agent/mcp_client.py +104 -0
- agent/providers/__init__.py +4 -0
- agent/providers/anthropic_provider.py +169 -0
- agent/providers/base.py +148 -0
- agent/providers/factory.py +35 -0
- agent/providers/openai_provider.py +194 -0
- agent/providers/system_prompt.py +132 -0
- agent/setup_wizard.py +309 -0
- agent/tools/__init__.py +15 -0
- agent/tools/a2a.py +56 -0
- agent/tools/base.py +52 -0
- agent/tools/diagram.py +131 -0
- agent/tools/doc_gen.py +163 -0
- agent/tools/fs.py +411 -0
- agent/tools/git_ops.py +145 -0
- agent/tools/registry.py +219 -0
- agent/tools/search_code.py +120 -0
- agent/tools/shell.py +118 -0
- agent/tools/web_search.py +105 -0
- agent/tui/__init__.py +3 -0
- agent/tui/app.py +557 -0
- agent/ui.py +263 -0
- devpilot_agentic_cli-1.0.0.dist-info/METADATA +288 -0
- devpilot_agentic_cli-1.0.0.dist-info/RECORD +35 -0
- devpilot_agentic_cli-1.0.0.dist-info/WHEEL +5 -0
- devpilot_agentic_cli-1.0.0.dist-info/entry_points.txt +2 -0
- devpilot_agentic_cli-1.0.0.dist-info/top_level.txt +1 -0
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)
|