devorch 0.1.2__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.
- cli/commands/ask.py +0 -0
- cli/commands/edit.py +0 -0
- cli/commands/run.py +0 -0
- cli/main.py +1527 -0
- config/permissions.py +285 -0
- config/profiles.py +0 -0
- config/settings.py +185 -0
- core/agent.py +433 -0
- core/context.py +0 -0
- core/executor.py +213 -0
- core/modes.py +141 -0
- core/planner.py +16 -0
- core/sessions.py +271 -0
- core/tasks.py +142 -0
- devorch-0.1.2.dist-info/METADATA +528 -0
- devorch-0.1.2.dist-info/RECORD +50 -0
- devorch-0.1.2.dist-info/WHEEL +4 -0
- devorch-0.1.2.dist-info/entry_points.txt +2 -0
- providers/__init__.py +130 -0
- providers/anthropic.py +127 -0
- providers/base.py +51 -0
- providers/custom.py +186 -0
- providers/deepseek.py +170 -0
- providers/gemini.py +192 -0
- providers/github_copilot.py +196 -0
- providers/groq.py +158 -0
- providers/kimi.py +181 -0
- providers/lmstudio.py +147 -0
- providers/local.py +214 -0
- providers/mistral.py +161 -0
- providers/openai.py +114 -0
- providers/openrouter.py +195 -0
- providers/together.py +159 -0
- schemas/message.py +32 -0
- schemas/task.py +115 -0
- schemas/tool.py +0 -0
- tools/__init__.py +30 -0
- tools/base.py +40 -0
- tools/edit.py +236 -0
- tools/filesystem.py +224 -0
- tools/git.py +0 -0
- tools/grep.py +280 -0
- tools/search.py +150 -0
- tools/shell.py +55 -0
- tools/task.py +91 -0
- tools/terminal.py +123 -0
- tools/terminal_session.py +298 -0
- tools/tests.py +0 -0
- tools/websearch.py +166 -0
- utils/logger.py +52 -0
tools/grep.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Grep Tool - Search for patterns within file contents.
|
|
3
|
+
Similar to ripgrep/grep, optimized for code search.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from tools.base import Tool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class GrepMatch:
|
|
18
|
+
"""A single grep match result."""
|
|
19
|
+
|
|
20
|
+
file: str
|
|
21
|
+
line_number: int
|
|
22
|
+
line_content: str
|
|
23
|
+
match_start: int
|
|
24
|
+
match_end: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GrepToolSchema(BaseModel):
|
|
28
|
+
pattern: str = Field(..., description="Regex pattern to search for in file contents.")
|
|
29
|
+
path: str = Field(default=".", description="File or directory to search in.")
|
|
30
|
+
include: str = Field(default="*", description="File pattern to include (e.g., '*.py', '*.js').")
|
|
31
|
+
context: int = Field(
|
|
32
|
+
default=0, description="Number of context lines before and after match (0-5)."
|
|
33
|
+
)
|
|
34
|
+
max_results: int = Field(default=50, description="Maximum number of results to return.")
|
|
35
|
+
case_sensitive: bool = Field(default=True, description="Whether search is case-sensitive.")
|
|
36
|
+
whole_word: bool = Field(default=False, description="Match whole words only.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class GrepTool(Tool):
|
|
40
|
+
"""
|
|
41
|
+
Searches for patterns within file contents.
|
|
42
|
+
Returns matching lines with file paths and line numbers.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name = "grep"
|
|
46
|
+
description = """Search for a pattern within file contents. Returns matching lines with file paths and line numbers.
|
|
47
|
+
Use this to find specific code, functions, variables, or text patterns across files.
|
|
48
|
+
Examples:
|
|
49
|
+
- Search for a function: pattern="def process_data"
|
|
50
|
+
- Search for imports: pattern="from typing import", include="*.py"
|
|
51
|
+
- Search for TODO comments: pattern="TODO|FIXME", include="*.py"
|
|
52
|
+
- Case-insensitive search: pattern="error", case_sensitive=false"""
|
|
53
|
+
args_schema = GrepToolSchema
|
|
54
|
+
|
|
55
|
+
# File extensions to search by default (text files)
|
|
56
|
+
TEXT_EXTENSIONS = {
|
|
57
|
+
".py",
|
|
58
|
+
".js",
|
|
59
|
+
".ts",
|
|
60
|
+
".jsx",
|
|
61
|
+
".tsx",
|
|
62
|
+
".java",
|
|
63
|
+
".c",
|
|
64
|
+
".cpp",
|
|
65
|
+
".h",
|
|
66
|
+
".hpp",
|
|
67
|
+
".go",
|
|
68
|
+
".rs",
|
|
69
|
+
".rb",
|
|
70
|
+
".php",
|
|
71
|
+
".swift",
|
|
72
|
+
".kt",
|
|
73
|
+
".scala",
|
|
74
|
+
".cs",
|
|
75
|
+
".html",
|
|
76
|
+
".css",
|
|
77
|
+
".scss",
|
|
78
|
+
".less",
|
|
79
|
+
".vue",
|
|
80
|
+
".svelte",
|
|
81
|
+
".json",
|
|
82
|
+
".yaml",
|
|
83
|
+
".yml",
|
|
84
|
+
".toml",
|
|
85
|
+
".xml",
|
|
86
|
+
".md",
|
|
87
|
+
".txt",
|
|
88
|
+
".rst",
|
|
89
|
+
".sh",
|
|
90
|
+
".bash",
|
|
91
|
+
".zsh",
|
|
92
|
+
".fish",
|
|
93
|
+
".ps1",
|
|
94
|
+
".bat",
|
|
95
|
+
".cmd",
|
|
96
|
+
".sql",
|
|
97
|
+
".graphql",
|
|
98
|
+
".proto",
|
|
99
|
+
".env",
|
|
100
|
+
".gitignore",
|
|
101
|
+
".dockerignore",
|
|
102
|
+
"Dockerfile",
|
|
103
|
+
"Makefile",
|
|
104
|
+
".cfg",
|
|
105
|
+
".ini",
|
|
106
|
+
".conf",
|
|
107
|
+
".config",
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Directories to skip
|
|
111
|
+
SKIP_DIRS = {
|
|
112
|
+
".git",
|
|
113
|
+
".svn",
|
|
114
|
+
".hg",
|
|
115
|
+
"node_modules",
|
|
116
|
+
"__pycache__",
|
|
117
|
+
".venv",
|
|
118
|
+
"venv",
|
|
119
|
+
"env",
|
|
120
|
+
".env",
|
|
121
|
+
"dist",
|
|
122
|
+
"build",
|
|
123
|
+
".next",
|
|
124
|
+
".nuxt",
|
|
125
|
+
"target",
|
|
126
|
+
"out",
|
|
127
|
+
".idea",
|
|
128
|
+
".vscode",
|
|
129
|
+
".pytest_cache",
|
|
130
|
+
".mypy_cache",
|
|
131
|
+
"coverage",
|
|
132
|
+
"htmlcov",
|
|
133
|
+
".tox",
|
|
134
|
+
"eggs",
|
|
135
|
+
"*.egg-info",
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
def _should_search_file(self, filepath: str, include_pattern: str) -> bool:
|
|
139
|
+
"""Check if file should be searched based on extension and pattern."""
|
|
140
|
+
filename = os.path.basename(filepath)
|
|
141
|
+
ext = os.path.splitext(filename)[1].lower()
|
|
142
|
+
|
|
143
|
+
# Check include pattern
|
|
144
|
+
if include_pattern != "*":
|
|
145
|
+
import fnmatch
|
|
146
|
+
|
|
147
|
+
if not fnmatch.fnmatch(filename, include_pattern):
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
# Check if it's a text file
|
|
151
|
+
if ext and ext not in self.TEXT_EXTENSIONS:
|
|
152
|
+
# Allow files without extension if they match pattern
|
|
153
|
+
if ext:
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
def _should_skip_dir(self, dirname: str) -> bool:
|
|
159
|
+
"""Check if directory should be skipped."""
|
|
160
|
+
return dirname in self.SKIP_DIRS or dirname.startswith(".")
|
|
161
|
+
|
|
162
|
+
def _search_file(
|
|
163
|
+
self, filepath: str, regex: re.Pattern, context: int, max_results: int, current_results: int
|
|
164
|
+
) -> list[dict]:
|
|
165
|
+
"""Search a single file for matches."""
|
|
166
|
+
results = []
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
with open(filepath, encoding="utf-8", errors="ignore") as f:
|
|
170
|
+
lines = f.readlines()
|
|
171
|
+
|
|
172
|
+
for i, line in enumerate(lines):
|
|
173
|
+
if current_results + len(results) >= max_results:
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
match = regex.search(line)
|
|
177
|
+
if match:
|
|
178
|
+
result = {
|
|
179
|
+
"file": filepath,
|
|
180
|
+
"line": i + 1,
|
|
181
|
+
"content": line.rstrip("\n\r"),
|
|
182
|
+
"match": match.group(),
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
# Add context lines if requested
|
|
186
|
+
if context > 0:
|
|
187
|
+
context_before = []
|
|
188
|
+
context_after = []
|
|
189
|
+
|
|
190
|
+
for j in range(max(0, i - context), i):
|
|
191
|
+
context_before.append(f"{j + 1}: {lines[j].rstrip()}")
|
|
192
|
+
|
|
193
|
+
for j in range(i + 1, min(len(lines), i + context + 1)):
|
|
194
|
+
context_after.append(f"{j + 1}: {lines[j].rstrip()}")
|
|
195
|
+
|
|
196
|
+
if context_before:
|
|
197
|
+
result["context_before"] = context_before
|
|
198
|
+
if context_after:
|
|
199
|
+
result["context_after"] = context_after
|
|
200
|
+
|
|
201
|
+
results.append(result)
|
|
202
|
+
|
|
203
|
+
except Exception:
|
|
204
|
+
pass # Skip files that can't be read
|
|
205
|
+
|
|
206
|
+
return results
|
|
207
|
+
|
|
208
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
209
|
+
pattern = arguments.get("pattern")
|
|
210
|
+
path = arguments.get("path", ".")
|
|
211
|
+
include = arguments.get("include", "*")
|
|
212
|
+
context = min(arguments.get("context", 0), 5) # Max 5 context lines
|
|
213
|
+
max_results = min(arguments.get("max_results", 50), 100) # Max 100 results
|
|
214
|
+
case_sensitive = arguments.get("case_sensitive", True)
|
|
215
|
+
whole_word = arguments.get("whole_word", False)
|
|
216
|
+
|
|
217
|
+
if not pattern:
|
|
218
|
+
return "Error: Pattern not provided."
|
|
219
|
+
|
|
220
|
+
# Build regex
|
|
221
|
+
try:
|
|
222
|
+
if whole_word:
|
|
223
|
+
pattern = rf"\b{pattern}\b"
|
|
224
|
+
|
|
225
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
226
|
+
regex = re.compile(pattern, flags)
|
|
227
|
+
except re.error as e:
|
|
228
|
+
return f"Error: Invalid regex pattern - {e}"
|
|
229
|
+
|
|
230
|
+
results = []
|
|
231
|
+
|
|
232
|
+
# Handle single file
|
|
233
|
+
if os.path.isfile(path):
|
|
234
|
+
results = self._search_file(path, regex, context, max_results, 0)
|
|
235
|
+
# Handle directory
|
|
236
|
+
elif os.path.isdir(path):
|
|
237
|
+
for root, dirs, files in os.walk(path):
|
|
238
|
+
# Skip certain directories
|
|
239
|
+
dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]
|
|
240
|
+
|
|
241
|
+
for filename in files:
|
|
242
|
+
if len(results) >= max_results:
|
|
243
|
+
break
|
|
244
|
+
|
|
245
|
+
filepath = os.path.join(root, filename)
|
|
246
|
+
|
|
247
|
+
if self._should_search_file(filepath, include):
|
|
248
|
+
file_results = self._search_file(
|
|
249
|
+
filepath, regex, context, max_results, len(results)
|
|
250
|
+
)
|
|
251
|
+
results.extend(file_results)
|
|
252
|
+
|
|
253
|
+
if len(results) >= max_results:
|
|
254
|
+
break
|
|
255
|
+
else:
|
|
256
|
+
return f"Error: Path '{path}' not found."
|
|
257
|
+
|
|
258
|
+
if not results:
|
|
259
|
+
return f"No matches found for pattern: {pattern}"
|
|
260
|
+
|
|
261
|
+
# Format output
|
|
262
|
+
output_lines = [f"Found {len(results)} match(es):\n"]
|
|
263
|
+
|
|
264
|
+
for r in results:
|
|
265
|
+
# Format: file:line: content
|
|
266
|
+
output_lines.append(f"{r['file']}:{r['line']}: {r['content']}")
|
|
267
|
+
|
|
268
|
+
if "context_before" in r:
|
|
269
|
+
for ctx in r["context_before"]:
|
|
270
|
+
output_lines.append(f" {ctx}")
|
|
271
|
+
output_lines.append(f" {r['line']}: {r['content']} <-- match")
|
|
272
|
+
if "context_after" in r:
|
|
273
|
+
for ctx in r["context_after"]:
|
|
274
|
+
output_lines.append(f" {ctx}")
|
|
275
|
+
output_lines.append("")
|
|
276
|
+
|
|
277
|
+
if len(results) >= max_results:
|
|
278
|
+
output_lines.append(f"\n(Results truncated at {max_results})")
|
|
279
|
+
|
|
280
|
+
return "\n".join(output_lines)
|
tools/search.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from tools.base import Tool
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SearchToolSchema(BaseModel):
|
|
11
|
+
pattern: str = Field(..., description="Glob pattern (e.g., '*.py', 'test_*.js', '**/*.md').")
|
|
12
|
+
directory: str = Field(default=".", description="Directory to search in.")
|
|
13
|
+
type: str = Field(default="all", description="Filter: 'file', 'dir', or 'all'.")
|
|
14
|
+
max_results: int = Field(default=100, description="Maximum number of results.")
|
|
15
|
+
include_hidden: bool = Field(default=False, description="Include hidden files/directories.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SearchTool(Tool):
|
|
19
|
+
name = "search"
|
|
20
|
+
description = """Search for files and directories matching a glob pattern.
|
|
21
|
+
|
|
22
|
+
Examples:
|
|
23
|
+
- Find Python files: pattern="*.py"
|
|
24
|
+
- Find test files: pattern="test_*.py"
|
|
25
|
+
- Find all in subdirs: pattern="**/*.js"
|
|
26
|
+
- Find specific file: pattern="**/config.yaml"
|
|
27
|
+
- Find directories: pattern="**/tests", type="dir"
|
|
28
|
+
- Find markdown docs: pattern="**/*.md", directory="docs"
|
|
29
|
+
|
|
30
|
+
Common patterns:
|
|
31
|
+
- *.ext - Files with extension in current dir
|
|
32
|
+
- **/*.ext - Files with extension in all subdirs
|
|
33
|
+
- **/name - File/dir with exact name in all subdirs
|
|
34
|
+
- prefix* - Files starting with prefix
|
|
35
|
+
- *suffix - Files ending with suffix"""
|
|
36
|
+
args_schema = SearchToolSchema
|
|
37
|
+
|
|
38
|
+
# Directories to skip by default
|
|
39
|
+
SKIP_DIRS = {
|
|
40
|
+
".git",
|
|
41
|
+
".svn",
|
|
42
|
+
".hg",
|
|
43
|
+
"node_modules",
|
|
44
|
+
"__pycache__",
|
|
45
|
+
".venv",
|
|
46
|
+
"venv",
|
|
47
|
+
"env",
|
|
48
|
+
".env",
|
|
49
|
+
"dist",
|
|
50
|
+
"build",
|
|
51
|
+
".next",
|
|
52
|
+
".nuxt",
|
|
53
|
+
"target",
|
|
54
|
+
"out",
|
|
55
|
+
".idea",
|
|
56
|
+
".vscode",
|
|
57
|
+
".pytest_cache",
|
|
58
|
+
".mypy_cache",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def _filter_results(
|
|
62
|
+
self, matches: list[str], type_filter: str, include_hidden: bool, max_results: int
|
|
63
|
+
) -> list[str]:
|
|
64
|
+
"""Filter and limit results."""
|
|
65
|
+
filtered = []
|
|
66
|
+
|
|
67
|
+
for match in matches:
|
|
68
|
+
# Skip hidden unless requested
|
|
69
|
+
if not include_hidden:
|
|
70
|
+
parts = match.replace("\\", "/").split("/")
|
|
71
|
+
if any(p.startswith(".") and p not in (".", "..") for p in parts):
|
|
72
|
+
continue
|
|
73
|
+
|
|
74
|
+
# Skip common ignored directories
|
|
75
|
+
if any(skip in parts for skip in self.SKIP_DIRS):
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
# Type filter
|
|
79
|
+
if type_filter == "file" and not os.path.isfile(match):
|
|
80
|
+
continue
|
|
81
|
+
if type_filter == "dir" and not os.path.isdir(match):
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
filtered.append(match)
|
|
85
|
+
|
|
86
|
+
if len(filtered) >= max_results:
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
return filtered
|
|
90
|
+
|
|
91
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
92
|
+
pattern = arguments.get("pattern")
|
|
93
|
+
directory = arguments.get("directory", ".")
|
|
94
|
+
type_filter = arguments.get("type", "all").lower()
|
|
95
|
+
max_results = min(arguments.get("max_results", 100), 500)
|
|
96
|
+
include_hidden = arguments.get("include_hidden", False)
|
|
97
|
+
|
|
98
|
+
if not pattern:
|
|
99
|
+
return "Error: Pattern not provided."
|
|
100
|
+
|
|
101
|
+
if not os.path.isdir(directory):
|
|
102
|
+
return f"Error: Directory '{directory}' not found."
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
# Handle different pattern formats
|
|
106
|
+
if pattern.startswith("**/"):
|
|
107
|
+
search_path = os.path.join(directory, pattern)
|
|
108
|
+
elif "**" in pattern:
|
|
109
|
+
search_path = os.path.join(directory, pattern)
|
|
110
|
+
else:
|
|
111
|
+
# Search recursively by default
|
|
112
|
+
search_path = os.path.join(directory, "**", pattern)
|
|
113
|
+
|
|
114
|
+
matches = glob.glob(search_path, recursive=True)
|
|
115
|
+
|
|
116
|
+
# Filter results
|
|
117
|
+
filtered = self._filter_results(matches, type_filter, include_hidden, max_results)
|
|
118
|
+
|
|
119
|
+
if not filtered:
|
|
120
|
+
return f"No files found matching: {pattern}"
|
|
121
|
+
|
|
122
|
+
# Format output with file info
|
|
123
|
+
output_lines = [f"Found {len(filtered)} result(s):\n"]
|
|
124
|
+
|
|
125
|
+
for match in filtered:
|
|
126
|
+
# Normalize path
|
|
127
|
+
rel_path = os.path.relpath(match, directory) if directory != "." else match
|
|
128
|
+
|
|
129
|
+
if os.path.isdir(match):
|
|
130
|
+
output_lines.append(f" [DIR] {rel_path}/")
|
|
131
|
+
else:
|
|
132
|
+
try:
|
|
133
|
+
size = os.path.getsize(match)
|
|
134
|
+
if size < 1024:
|
|
135
|
+
size_str = f"{size}B"
|
|
136
|
+
elif size < 1024 * 1024:
|
|
137
|
+
size_str = f"{size // 1024}KB"
|
|
138
|
+
else:
|
|
139
|
+
size_str = f"{size // (1024 * 1024)}MB"
|
|
140
|
+
output_lines.append(f" {size_str:>6} {rel_path}")
|
|
141
|
+
except Exception:
|
|
142
|
+
output_lines.append(f" {'?':>6} {rel_path}")
|
|
143
|
+
|
|
144
|
+
if len(matches) > max_results:
|
|
145
|
+
output_lines.append(f"\n(Showing {max_results} of {len(matches)} results)")
|
|
146
|
+
|
|
147
|
+
return "\n".join(output_lines)
|
|
148
|
+
|
|
149
|
+
except Exception as e:
|
|
150
|
+
return f"Search error: {str(e)}"
|
tools/shell.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel, Field
|
|
5
|
+
|
|
6
|
+
from tools.base import Tool
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ShellToolSchema(BaseModel):
|
|
10
|
+
command: str = Field(..., description="The shell command to execute.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ShellTool(Tool):
|
|
14
|
+
name = "shell"
|
|
15
|
+
description = """\
|
|
16
|
+
Executes a shell command and captures its output.
|
|
17
|
+
|
|
18
|
+
Use this for short-lived commands that return output, such as:
|
|
19
|
+
- Package management: `npm install`, `pip install`, `cargo build`
|
|
20
|
+
- Version control: `git status`, `git add`, `git commit`, `git clone`
|
|
21
|
+
- File operations: `mkdir`, `cp`, `mv`, `rm`
|
|
22
|
+
- Inspecting output: `cat`, `echo`, `pwd`, `ls`
|
|
23
|
+
|
|
24
|
+
For long-running servers or interactive scaffold tools, use `open_terminal` instead."""
|
|
25
|
+
args_schema = ShellToolSchema
|
|
26
|
+
|
|
27
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
28
|
+
try:
|
|
29
|
+
command = arguments.get("command")
|
|
30
|
+
if not command:
|
|
31
|
+
return "Error: No command provided."
|
|
32
|
+
|
|
33
|
+
result = subprocess.run(
|
|
34
|
+
command,
|
|
35
|
+
shell=True,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
text=True,
|
|
38
|
+
check=False,
|
|
39
|
+
timeout=120,
|
|
40
|
+
encoding="utf-8",
|
|
41
|
+
errors="replace",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
output = ""
|
|
45
|
+
if result.stdout:
|
|
46
|
+
output += f"STDOUT:\n{result.stdout}\n"
|
|
47
|
+
if result.stderr:
|
|
48
|
+
output += f"STDERR:\n{result.stderr}\n"
|
|
49
|
+
|
|
50
|
+
return output if output else f"Command completed with exit code {result.returncode}."
|
|
51
|
+
|
|
52
|
+
except subprocess.TimeoutExpired:
|
|
53
|
+
return "Error: Command timed out after 120 seconds."
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return f"Failed to execute command: {str(e)}"
|
tools/task.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Task tool for AI to track work progress."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from core.tasks import get_task_manager
|
|
8
|
+
from tools.base import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TaskItem(BaseModel):
|
|
12
|
+
"""A single task item."""
|
|
13
|
+
|
|
14
|
+
content: str = Field(
|
|
15
|
+
..., description="What needs to be done (imperative form, e.g., 'Run tests')"
|
|
16
|
+
)
|
|
17
|
+
status: str = Field("pending", description="Task status: pending, in_progress, or completed")
|
|
18
|
+
activeForm: str = Field(
|
|
19
|
+
..., description="Present continuous form shown during execution (e.g., 'Running tests')"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskToolSchema(BaseModel):
|
|
24
|
+
"""Schema for the task tool."""
|
|
25
|
+
|
|
26
|
+
todos: list[TaskItem] = Field(..., description="The updated todo list with all tasks")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskTool(Tool):
|
|
30
|
+
"""
|
|
31
|
+
Tool for AI to manage a task list and track progress.
|
|
32
|
+
|
|
33
|
+
Use this tool to:
|
|
34
|
+
- Create a task list when starting multi-step work
|
|
35
|
+
- Update task status as you complete items
|
|
36
|
+
- Show the user what you're working on
|
|
37
|
+
|
|
38
|
+
Guidelines:
|
|
39
|
+
- Only ONE task should be 'in_progress' at a time
|
|
40
|
+
- Mark tasks 'completed' immediately after finishing
|
|
41
|
+
- Content should be imperative (e.g., "Fix bug")
|
|
42
|
+
- ActiveForm should be present continuous (e.g., "Fixing bug")
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name = "task"
|
|
46
|
+
description = """Create and manage a task list to track progress on multi-step work.
|
|
47
|
+
Use this when:
|
|
48
|
+
- Working on tasks with 3+ steps
|
|
49
|
+
- User provides multiple items to do
|
|
50
|
+
- You want to show progress to the user
|
|
51
|
+
|
|
52
|
+
Task status options: pending, in_progress, completed
|
|
53
|
+
Only ONE task should be in_progress at a time."""
|
|
54
|
+
|
|
55
|
+
args_schema = TaskToolSchema
|
|
56
|
+
|
|
57
|
+
def run(self, arguments: dict[str, Any]) -> str:
|
|
58
|
+
"""Execute the task tool."""
|
|
59
|
+
todos = arguments.get("todos", [])
|
|
60
|
+
|
|
61
|
+
if not todos:
|
|
62
|
+
return "No tasks provided."
|
|
63
|
+
|
|
64
|
+
# Convert to list of dicts if needed
|
|
65
|
+
task_list = []
|
|
66
|
+
for item in todos:
|
|
67
|
+
if isinstance(item, dict):
|
|
68
|
+
task_list.append(item)
|
|
69
|
+
else:
|
|
70
|
+
# Pydantic model
|
|
71
|
+
task_list.append(
|
|
72
|
+
{
|
|
73
|
+
"content": item.content,
|
|
74
|
+
"status": item.status,
|
|
75
|
+
"activeForm": item.activeForm,
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
# Update task manager
|
|
80
|
+
task_manager = get_task_manager()
|
|
81
|
+
task_manager.set_tasks(task_list)
|
|
82
|
+
|
|
83
|
+
# Return summary
|
|
84
|
+
completed = sum(1 for t in task_list if t.get("status") == "completed")
|
|
85
|
+
in_progress = sum(1 for t in task_list if t.get("status") == "in_progress")
|
|
86
|
+
pending = sum(1 for t in task_list if t.get("status") == "pending")
|
|
87
|
+
|
|
88
|
+
current = next((t for t in task_list if t.get("status") == "in_progress"), None)
|
|
89
|
+
current_text = f" Current: {current['activeForm']}" if current else ""
|
|
90
|
+
|
|
91
|
+
return f"Tasks updated: {completed} completed, {in_progress} in progress, {pending} pending.{current_text}"
|
tools/terminal.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from tools.base import Tool
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OpenTerminalSchema(BaseModel):
|
|
12
|
+
command: str = Field(
|
|
13
|
+
...,
|
|
14
|
+
description=(
|
|
15
|
+
"The command to run in a new terminal window. "
|
|
16
|
+
"Use this for long-running servers (npm run dev, vite, uvicorn, flask run, etc.), "
|
|
17
|
+
"interactive scaffold tools (npm create, npx create-*, ng new, etc.), "
|
|
18
|
+
"or anything else that would block the main session if run normally."
|
|
19
|
+
),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OpenTerminalTool(Tool):
|
|
24
|
+
name = "open_terminal"
|
|
25
|
+
description = """\
|
|
26
|
+
Opens a new terminal window and runs the given command inside it.
|
|
27
|
+
|
|
28
|
+
Use this tool whenever you need to:
|
|
29
|
+
- Start a development server or daemon (e.g. `npm run dev`, `vite`, `uvicorn app:main`, `flask run`, `next dev`)
|
|
30
|
+
- Run an interactive scaffold that prompts the user (e.g. `npm create vite@latest`, `npx create-next-app`, `ng new myapp`)
|
|
31
|
+
- Run any long-running process that should NOT block the current session
|
|
32
|
+
|
|
33
|
+
The main DevOrch session remains fully interactive while the command runs in its own window.
|
|
34
|
+
After calling this tool, continue the conversation normally — do NOT wait for the command to finish."""
|
|
35
|
+
args_schema = OpenTerminalSchema
|
|
36
|
+
|
|
37
|
+
def run(self, arguments: dict[str, Any]) -> Any:
|
|
38
|
+
command = arguments.get("command", "").strip()
|
|
39
|
+
if not command:
|
|
40
|
+
return "Error: No command provided."
|
|
41
|
+
|
|
42
|
+
working_dir = os.getcwd()
|
|
43
|
+
system = sys.platform
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
if system == "win32":
|
|
47
|
+
subprocess.Popen(
|
|
48
|
+
["cmd", "/c", "start", "cmd", "/k", command],
|
|
49
|
+
cwd=working_dir,
|
|
50
|
+
)
|
|
51
|
+
return (
|
|
52
|
+
f"✓ Opened new terminal window\n\n"
|
|
53
|
+
f"Command: {command}\n\n"
|
|
54
|
+
f"The command is now running in a separate window. "
|
|
55
|
+
f"You can continue the conversation here."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
elif system == "darwin":
|
|
59
|
+
escaped_command = command.replace('"', '\\"')
|
|
60
|
+
escaped_dir = working_dir.replace('"', '\\"')
|
|
61
|
+
subprocess.Popen(
|
|
62
|
+
[
|
|
63
|
+
"osascript",
|
|
64
|
+
"-e",
|
|
65
|
+
f'tell app "Terminal" to do script "cd \\"{escaped_dir}\\" && {escaped_command}"',
|
|
66
|
+
]
|
|
67
|
+
)
|
|
68
|
+
return (
|
|
69
|
+
f"✓ Opened new Terminal window\n\n"
|
|
70
|
+
f"Command: {command}\n\n"
|
|
71
|
+
f"The command is now running in a separate window. "
|
|
72
|
+
f"You can continue the conversation here."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
else:
|
|
76
|
+
# Linux: try common terminal emulators in order
|
|
77
|
+
terminals = [
|
|
78
|
+
[
|
|
79
|
+
"gnome-terminal",
|
|
80
|
+
"--working-directory",
|
|
81
|
+
working_dir,
|
|
82
|
+
"--",
|
|
83
|
+
"bash",
|
|
84
|
+
"-c",
|
|
85
|
+
f"{command}; exec bash",
|
|
86
|
+
],
|
|
87
|
+
["konsole", "--workdir", working_dir, "-e", f"bash -c '{command}; exec bash'"],
|
|
88
|
+
["xterm", "-e", f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'"],
|
|
89
|
+
[
|
|
90
|
+
"x-terminal-emulator",
|
|
91
|
+
"-e",
|
|
92
|
+
f"bash -c 'cd \"{working_dir}\" && {command}; exec bash'",
|
|
93
|
+
],
|
|
94
|
+
]
|
|
95
|
+
for term_cmd in terminals:
|
|
96
|
+
try:
|
|
97
|
+
subprocess.Popen(term_cmd)
|
|
98
|
+
return (
|
|
99
|
+
f"✓ Opened new terminal window\n\n"
|
|
100
|
+
f"Command: {command}\n\n"
|
|
101
|
+
f"The command is now running in a separate window. "
|
|
102
|
+
f"You can continue the conversation here."
|
|
103
|
+
)
|
|
104
|
+
except FileNotFoundError:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# Fallback: background process
|
|
108
|
+
process = subprocess.Popen(
|
|
109
|
+
command,
|
|
110
|
+
shell=True,
|
|
111
|
+
stdout=subprocess.DEVNULL,
|
|
112
|
+
stderr=subprocess.DEVNULL,
|
|
113
|
+
start_new_session=True,
|
|
114
|
+
cwd=working_dir,
|
|
115
|
+
)
|
|
116
|
+
return (
|
|
117
|
+
f"✓ Started in background (PID: {process.pid})\n\n"
|
|
118
|
+
f"Command: {command}\n\n"
|
|
119
|
+
f"Note: No GUI terminal emulator found — process is running in the background."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
except Exception as e:
|
|
123
|
+
return f"Error opening terminal: {str(e)}\n\nRun manually: {command}"
|