mdan-cli 2.6.0 → 2.7.0

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.
@@ -0,0 +1,355 @@
1
+ """File Operations Tool for CrewAI Agents"""
2
+
3
+ import os
4
+ import json
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import Optional, List, Dict, Any, Union
8
+ from dataclasses import dataclass
9
+ import logging
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class FileResult:
16
+ """Represents a file operation result"""
17
+
18
+ success: bool
19
+ message: str
20
+ path: Optional[str] = None
21
+ content: Optional[str] = None
22
+ metadata: Optional[Dict[str, Any]] = None
23
+
24
+
25
+ class FileTool:
26
+ """Tool for file system operations"""
27
+
28
+ def __init__(self, base_path: Optional[str] = None):
29
+ """
30
+ Initialize FileTool
31
+
32
+ Args:
33
+ base_path: Base directory for file operations. Defaults to current directory
34
+ """
35
+ self.base_path = Path(base_path) if base_path else Path.cwd()
36
+
37
+ def read_file(self, file_path: str, encoding: str = "utf-8") -> FileResult:
38
+ """
39
+ Read content from a file
40
+
41
+ Args:
42
+ file_path: Path to the file (relative to base_path or absolute)
43
+ encoding: File encoding
44
+
45
+ Returns:
46
+ FileResult with file content
47
+ """
48
+ try:
49
+ path = self._resolve_path(file_path)
50
+
51
+ if not path.exists():
52
+ return FileResult(success=False, message=f"File not found: {path}")
53
+
54
+ content = path.read_text(encoding=encoding)
55
+
56
+ return FileResult(
57
+ success=True,
58
+ message=f"Successfully read {len(content)} characters",
59
+ path=str(path),
60
+ content=content,
61
+ metadata={
62
+ "size": path.stat().st_size,
63
+ "modified": path.stat().st_mtime,
64
+ "encoding": encoding,
65
+ },
66
+ )
67
+
68
+ except Exception as e:
69
+ logger.error(f"Failed to read file {file_path}: {str(e)}")
70
+ return FileResult(success=False, message=f"Failed to read file: {str(e)}")
71
+
72
+ def write_file(
73
+ self,
74
+ file_path: str,
75
+ content: str,
76
+ encoding: str = "utf-8",
77
+ create_dirs: bool = True,
78
+ ) -> FileResult:
79
+ """
80
+ Write content to a file
81
+
82
+ Args:
83
+ file_path: Path to the file (relative to base_path or absolute)
84
+ content: Content to write
85
+ encoding: File encoding
86
+ create_dirs: Create parent directories if they don't exist
87
+
88
+ Returns:
89
+ FileResult with operation status
90
+ """
91
+ try:
92
+ path = self._resolve_path(file_path)
93
+
94
+ if create_dirs:
95
+ path.parent.mkdir(parents=True, exist_ok=True)
96
+
97
+ path.write_text(content, encoding=encoding)
98
+
99
+ return FileResult(
100
+ success=True,
101
+ message=f"Successfully wrote {len(content)} characters",
102
+ path=str(path),
103
+ metadata={"size": path.stat().st_size, "encoding": encoding},
104
+ )
105
+
106
+ except Exception as e:
107
+ logger.error(f"Failed to write file {file_path}: {str(e)}")
108
+ return FileResult(success=False, message=f"Failed to write file: {str(e)}")
109
+
110
+ def append_file(
111
+ self, file_path: str, content: str, encoding: str = "utf-8"
112
+ ) -> FileResult:
113
+ """
114
+ Append content to a file
115
+
116
+ Args:
117
+ file_path: Path to the file
118
+ content: Content to append
119
+ encoding: File encoding
120
+
121
+ Returns:
122
+ FileResult with operation status
123
+ """
124
+ try:
125
+ path = self._resolve_path(file_path)
126
+
127
+ if not path.exists():
128
+ return FileResult(success=False, message=f"File not found: {path}")
129
+
130
+ with open(path, "a", encoding=encoding) as f:
131
+ f.write(content)
132
+
133
+ return FileResult(
134
+ success=True,
135
+ message=f"Successfully appended {len(content)} characters",
136
+ path=str(path),
137
+ )
138
+
139
+ except Exception as e:
140
+ logger.error(f"Failed to append to file {file_path}: {str(e)}")
141
+ return FileResult(
142
+ success=False, message=f"Failed to append to file: {str(e)}"
143
+ )
144
+
145
+ def delete_file(self, file_path: str) -> FileResult:
146
+ """
147
+ Delete a file
148
+
149
+ Args:
150
+ file_path: Path to the file
151
+
152
+ Returns:
153
+ FileResult with operation status
154
+ """
155
+ try:
156
+ path = self._resolve_path(file_path)
157
+
158
+ if not path.exists():
159
+ return FileResult(success=False, message=f"File not found: {path}")
160
+
161
+ path.unlink()
162
+
163
+ return FileResult(
164
+ success=True, message=f"Successfully deleted file", path=str(path)
165
+ )
166
+
167
+ except Exception as e:
168
+ logger.error(f"Failed to delete file {file_path}: {str(e)}")
169
+ return FileResult(success=False, message=f"Failed to delete file: {str(e)}")
170
+
171
+ def list_directory(
172
+ self,
173
+ dir_path: str = ".",
174
+ pattern: Optional[str] = None,
175
+ recursive: bool = False,
176
+ ) -> FileResult:
177
+ """
178
+ List files in a directory
179
+
180
+ Args:
181
+ dir_path: Path to the directory
182
+ pattern: Glob pattern to filter files (e.g., "*.py")
183
+ recursive: Whether to search recursively
184
+
185
+ Returns:
186
+ FileResult with list of files
187
+ """
188
+ try:
189
+ path = self._resolve_path(dir_path)
190
+
191
+ if not path.exists() or not path.is_dir():
192
+ return FileResult(success=False, message=f"Directory not found: {path}")
193
+
194
+ if recursive:
195
+ files = list(path.rglob(pattern)) if pattern else list(path.rglob("*"))
196
+ else:
197
+ files = list(path.glob(pattern)) if pattern else list(path.glob("*"))
198
+
199
+ file_list = []
200
+ for f in files:
201
+ if f.is_file():
202
+ file_list.append(
203
+ {
204
+ "name": f.name,
205
+ "path": str(f.relative_to(self.base_path)),
206
+ "size": f.stat().st_size,
207
+ "is_dir": False,
208
+ }
209
+ )
210
+ elif f.is_dir():
211
+ file_list.append(
212
+ {
213
+ "name": f.name,
214
+ "path": str(f.relative_to(self.base_path)),
215
+ "is_dir": True,
216
+ }
217
+ )
218
+
219
+ return FileResult(
220
+ success=True,
221
+ message=f"Found {len(file_list)} items",
222
+ path=str(path),
223
+ content=json.dumps(file_list, indent=2),
224
+ metadata={"count": len(file_list)},
225
+ )
226
+
227
+ except Exception as e:
228
+ logger.error(f"Failed to list directory {dir_path}: {str(e)}")
229
+ return FileResult(
230
+ success=False, message=f"Failed to list directory: {str(e)}"
231
+ )
232
+
233
+ def create_directory(self, dir_path: str, parents: bool = True) -> FileResult:
234
+ """
235
+ Create a directory
236
+
237
+ Args:
238
+ dir_path: Path to the directory
239
+ parents: Create parent directories if needed
240
+
241
+ Returns:
242
+ FileResult with operation status
243
+ """
244
+ try:
245
+ path = self._resolve_path(dir_path)
246
+ path.mkdir(parents=parents, exist_ok=True)
247
+
248
+ return FileResult(
249
+ success=True, message=f"Successfully created directory", path=str(path)
250
+ )
251
+
252
+ except Exception as e:
253
+ logger.error(f"Failed to create directory {dir_path}: {str(e)}")
254
+ return FileResult(
255
+ success=False, message=f"Failed to create directory: {str(e)}"
256
+ )
257
+
258
+ def copy_file(self, src_path: str, dst_path: str) -> FileResult:
259
+ """
260
+ Copy a file
261
+
262
+ Args:
263
+ src_path: Source file path
264
+ dst_path: Destination file path
265
+
266
+ Returns:
267
+ FileResult with operation status
268
+ """
269
+ try:
270
+ src = self._resolve_path(src_path)
271
+ dst = self._resolve_path(dst_path)
272
+
273
+ if not src.exists():
274
+ return FileResult(
275
+ success=False, message=f"Source file not found: {src}"
276
+ )
277
+
278
+ dst.parent.mkdir(parents=True, exist_ok=True)
279
+ shutil.copy2(src, dst)
280
+
281
+ return FileResult(
282
+ success=True, message=f"Successfully copied file", path=str(dst)
283
+ )
284
+
285
+ except Exception as e:
286
+ logger.error(f"Failed to copy file {src_path} to {dst_path}: {str(e)}")
287
+ return FileResult(success=False, message=f"Failed to copy file: {str(e)}")
288
+
289
+ def move_file(self, src_path: str, dst_path: str) -> FileResult:
290
+ """
291
+ Move a file
292
+
293
+ Args:
294
+ src_path: Source file path
295
+ dst_path: Destination file path
296
+
297
+ Returns:
298
+ FileResult with operation status
299
+ """
300
+ try:
301
+ src = self._resolve_path(src_path)
302
+ dst = self._resolve_path(dst_path)
303
+
304
+ if not src.exists():
305
+ return FileResult(
306
+ success=False, message=f"Source file not found: {src}"
307
+ )
308
+
309
+ dst.parent.mkdir(parents=True, exist_ok=True)
310
+ shutil.move(str(src), str(dst))
311
+
312
+ return FileResult(
313
+ success=True, message=f"Successfully moved file", path=str(dst)
314
+ )
315
+
316
+ except Exception as e:
317
+ logger.error(f"Failed to move file {src_path} to {dst_path}: {str(e)}")
318
+ return FileResult(success=False, message=f"Failed to move file: {str(e)}")
319
+
320
+ def file_exists(self, file_path: str) -> bool:
321
+ """Check if a file exists"""
322
+ path = self._resolve_path(file_path)
323
+ return path.exists() and path.is_file()
324
+
325
+ def directory_exists(self, dir_path: str) -> bool:
326
+ """Check if a directory exists"""
327
+ path = self._resolve_path(dir_path)
328
+ return path.exists() and path.is_dir()
329
+
330
+ def get_file_info(self, file_path: str) -> Optional[Dict[str, Any]]:
331
+ """Get file metadata"""
332
+ try:
333
+ path = self._resolve_path(file_path)
334
+ if not path.exists():
335
+ return None
336
+
337
+ stat = path.stat()
338
+ return {
339
+ "name": path.name,
340
+ "path": str(path),
341
+ "size": stat.st_size,
342
+ "created": stat.st_ctime,
343
+ "modified": stat.st_mtime,
344
+ "is_file": path.is_file(),
345
+ "is_dir": path.is_dir(),
346
+ }
347
+ except Exception:
348
+ return None
349
+
350
+ def _resolve_path(self, path_str: str) -> Path:
351
+ """Resolve a path relative to base_path"""
352
+ path = Path(path_str)
353
+ if path.is_absolute():
354
+ return path
355
+ return self.base_path / path
@@ -0,0 +1,169 @@
1
+ """SerperDevTool Wrapper for Web Search"""
2
+
3
+ import os
4
+ from typing import Optional, Dict, Any, List
5
+ from dataclasses import dataclass
6
+
7
+ try:
8
+ from crewai_tools import SerperDevTool as CrewAISerperTool
9
+
10
+ CREWAI_AVAILABLE = True
11
+ except ImportError:
12
+ CREWAI_AVAILABLE = False
13
+
14
+
15
+ @dataclass
16
+ class SearchResult:
17
+ """Represents a single search result"""
18
+
19
+ title: str
20
+ link: str
21
+ snippet: str
22
+ position: int
23
+
24
+
25
+ @dataclass
26
+ class SearchResponse:
27
+ """Represents the complete search response"""
28
+
29
+ query: str
30
+ results: List[SearchResult]
31
+ total_results: int
32
+ search_time: float
33
+
34
+
35
+ class SerperTool:
36
+ """Wrapper around CrewAI SerperDevTool for web search capabilities"""
37
+
38
+ def __init__(self, api_key: Optional[str] = None):
39
+ """
40
+ Initialize SerperTool
41
+
42
+ Args:
43
+ api_key: Serper API key. If None, reads from SERPER_API_KEY env var
44
+ """
45
+ self.api_key = api_key or os.getenv("SERPER_API_KEY")
46
+
47
+ if not self.api_key:
48
+ raise ValueError(
49
+ "SERPER_API_KEY not found. Set it as environment variable "
50
+ "or pass it to the constructor."
51
+ )
52
+
53
+ if not CREWAI_AVAILABLE:
54
+ raise ImportError(
55
+ "crewai-tools is not installed. Install it with: "
56
+ "pip install 'crewai[tools]'"
57
+ )
58
+
59
+ self._tool = CrewAISerperTool()
60
+
61
+ def search(
62
+ self, query: str, num_results: int = 10, search_type: str = "search"
63
+ ) -> SearchResponse:
64
+ """
65
+ Perform a web search using Serper API
66
+
67
+ Args:
68
+ query: Search query string
69
+ num_results: Number of results to return (default: 10)
70
+ search_type: Type of search - 'search', 'news', 'images', 'places'
71
+
72
+ Returns:
73
+ SearchResponse with results
74
+ """
75
+ try:
76
+ import time
77
+
78
+ start_time = time.time()
79
+
80
+ result = self._tool._run(
81
+ query=query, n=num_results, search_type=search_type
82
+ )
83
+
84
+ search_time = time.time() - start_time
85
+
86
+ return self._parse_response(query, result, search_time)
87
+
88
+ except Exception as e:
89
+ raise RuntimeError(f"Search failed: {str(e)}") from e
90
+
91
+ def _parse_response(
92
+ self, query: str, raw_result: Any, search_time: float
93
+ ) -> SearchResponse:
94
+ """Parse raw Serper response into structured format"""
95
+ results = []
96
+
97
+ if isinstance(raw_result, dict):
98
+ organic = raw_result.get("organic", [])
99
+ total = raw_result.get("searchInformation", {}).get(
100
+ "totalResults", len(organic)
101
+ )
102
+
103
+ for idx, item in enumerate(organic):
104
+ results.append(
105
+ SearchResult(
106
+ title=item.get("title", ""),
107
+ link=item.get("link", ""),
108
+ snippet=item.get("snippet", ""),
109
+ position=idx + 1,
110
+ )
111
+ )
112
+ elif isinstance(raw_result, str):
113
+ results.append(
114
+ SearchResult(
115
+ title="Search Result", link="", snippet=raw_result, position=1
116
+ )
117
+ )
118
+ total = 1
119
+
120
+ return SearchResponse(
121
+ query=query, results=results, total_results=total, search_time=search_time
122
+ )
123
+
124
+ def search_news(self, query: str, num_results: int = 10) -> SearchResponse:
125
+ """Search for news articles"""
126
+ return self.search(query, num_results, search_type="news")
127
+
128
+ def search_images(self, query: str, num_results: int = 10) -> SearchResponse:
129
+ """Search for images"""
130
+ return self.search(query, num_results, search_type="images")
131
+
132
+ def search_places(self, query: str, num_results: int = 10) -> SearchResponse:
133
+ """Search for places"""
134
+ return self.search(query, num_results, search_type="places")
135
+
136
+ def get_top_result(self, query: str) -> Optional[SearchResult]:
137
+ """Get the top search result only"""
138
+ response = self.search(query, num_results=1)
139
+ return response.results[0] if response.results else None
140
+
141
+ def format_results(
142
+ self, response: SearchResponse, max_snippet_length: int = 200
143
+ ) -> str:
144
+ """Format search results as readable text"""
145
+ lines = [
146
+ f"Query: {response.query}",
147
+ f"Found {response.total_results} results in {response.search_time:.2f}s",
148
+ "",
149
+ ]
150
+
151
+ for result in response.results:
152
+ snippet = result.snippet[:max_snippet_length]
153
+ if len(result.snippet) > max_snippet_length:
154
+ snippet += "..."
155
+
156
+ lines.extend(
157
+ [
158
+ f"{result.position}. {result.title}",
159
+ f" {result.link}",
160
+ f" {snippet}",
161
+ "",
162
+ ]
163
+ )
164
+
165
+ return "\n".join(lines)
166
+
167
+ def to_crewai_tool(self):
168
+ """Return the underlying CrewAI tool for direct use"""
169
+ return self._tool