cognautic-cli 1.1.1__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.
@@ -0,0 +1,292 @@
1
+ """
2
+ Command runner tool for executing shell commands
3
+ """
4
+
5
+ import asyncio
6
+ import subprocess
7
+ import psutil
8
+ from typing import List, Dict, Any, Optional
9
+ import signal
10
+ import os
11
+
12
+ from .base import BaseTool, ToolResult, PermissionLevel
13
+
14
+
15
+ class CommandRunnerTool(BaseTool):
16
+ """Tool for executing shell commands and system operations"""
17
+
18
+ def __init__(self):
19
+ super().__init__(
20
+ name="command_runner",
21
+ description="Execute shell commands and system operations",
22
+ permission_level=PermissionLevel.SYSTEM_OPERATIONS
23
+ )
24
+ self.running_processes = {}
25
+
26
+ def get_capabilities(self) -> List[str]:
27
+ return [
28
+ "run_command",
29
+ "run_async_command",
30
+ "get_command_output",
31
+ "kill_process",
32
+ "check_process_status"
33
+ ]
34
+
35
+ async def execute(self, operation: str, **kwargs) -> ToolResult:
36
+ """Execute command operation"""
37
+
38
+ operations = {
39
+ 'run_command': self._run_command,
40
+ 'run_async_command': self._run_async_command,
41
+ 'get_command_output': self._get_command_output,
42
+ 'kill_process': self._kill_process,
43
+ 'check_process_status': self._check_process_status
44
+ }
45
+
46
+ if operation not in operations:
47
+ return ToolResult(
48
+ success=False,
49
+ error=f"Unknown operation: {operation}"
50
+ )
51
+
52
+ try:
53
+ result = await operations[operation](**kwargs)
54
+ return ToolResult(success=True, data=result)
55
+ except Exception as e:
56
+ return ToolResult(success=False, error=str(e))
57
+
58
+ async def _run_command(
59
+ self,
60
+ command: str,
61
+ cwd: str = None,
62
+ timeout: int = 300,
63
+ capture_output: bool = True,
64
+ shell: bool = True
65
+ ) -> Dict[str, Any]:
66
+ """Run a command synchronously (default timeout: 5 minutes for long-running commands)"""
67
+
68
+ try:
69
+ if shell:
70
+ process = await asyncio.create_subprocess_shell(
71
+ command,
72
+ cwd=cwd,
73
+ stdout=subprocess.PIPE if capture_output else None,
74
+ stderr=subprocess.PIPE if capture_output else None
75
+ )
76
+ else:
77
+ cmd_parts = command.split()
78
+ process = await asyncio.create_subprocess_exec(
79
+ *cmd_parts,
80
+ cwd=cwd,
81
+ stdout=subprocess.PIPE if capture_output else None,
82
+ stderr=subprocess.PIPE if capture_output else None
83
+ )
84
+
85
+ try:
86
+ stdout, stderr = await asyncio.wait_for(
87
+ process.communicate(),
88
+ timeout=timeout
89
+ )
90
+
91
+ return {
92
+ 'command': command,
93
+ 'return_code': process.returncode,
94
+ 'stdout': stdout.decode('utf-8') if stdout else '',
95
+ 'stderr': stderr.decode('utf-8') if stderr else '',
96
+ 'success': process.returncode == 0
97
+ }
98
+
99
+ except asyncio.TimeoutError:
100
+ process.kill()
101
+ await process.wait()
102
+ raise TimeoutError(f"Command timed out after {timeout} seconds")
103
+
104
+ except Exception as e:
105
+ return {
106
+ 'command': command,
107
+ 'return_code': -1,
108
+ 'stdout': '',
109
+ 'stderr': str(e),
110
+ 'success': False,
111
+ 'error': str(e)
112
+ }
113
+
114
+ async def _run_async_command(
115
+ self,
116
+ command: str,
117
+ cwd: str = None,
118
+ shell: bool = True
119
+ ) -> Dict[str, Any]:
120
+ """Run a command asynchronously and return process ID"""
121
+
122
+ try:
123
+ if shell:
124
+ process = await asyncio.create_subprocess_shell(
125
+ command,
126
+ cwd=cwd,
127
+ stdout=subprocess.PIPE,
128
+ stderr=subprocess.PIPE
129
+ )
130
+ else:
131
+ cmd_parts = command.split()
132
+ process = await asyncio.create_subprocess_exec(
133
+ *cmd_parts,
134
+ cwd=cwd,
135
+ stdout=subprocess.PIPE,
136
+ stderr=subprocess.PIPE
137
+ )
138
+
139
+ process_id = str(process.pid)
140
+ self.running_processes[process_id] = {
141
+ 'process': process,
142
+ 'command': command,
143
+ 'cwd': cwd,
144
+ 'started_at': asyncio.get_event_loop().time()
145
+ }
146
+
147
+ return {
148
+ 'process_id': process_id,
149
+ 'command': command,
150
+ 'pid': process.pid,
151
+ 'status': 'running'
152
+ }
153
+
154
+ except Exception as e:
155
+ raise Exception(f"Failed to start async command: {str(e)}")
156
+
157
+ async def _get_command_output(self, process_id: str) -> Dict[str, Any]:
158
+ """Get output from an async command"""
159
+
160
+ if process_id not in self.running_processes:
161
+ raise ValueError(f"Process ID not found: {process_id}")
162
+
163
+ process_info = self.running_processes[process_id]
164
+ process = process_info['process']
165
+
166
+ # Check if process is still running
167
+ if process.returncode is None:
168
+ # Process is still running
169
+ return {
170
+ 'process_id': process_id,
171
+ 'status': 'running',
172
+ 'return_code': None,
173
+ 'stdout': '',
174
+ 'stderr': '',
175
+ 'finished': False
176
+ }
177
+ else:
178
+ # Process has finished
179
+ try:
180
+ stdout, stderr = await process.communicate()
181
+
182
+ result = {
183
+ 'process_id': process_id,
184
+ 'status': 'finished',
185
+ 'return_code': process.returncode,
186
+ 'stdout': stdout.decode('utf-8') if stdout else '',
187
+ 'stderr': stderr.decode('utf-8') if stderr else '',
188
+ 'finished': True,
189
+ 'success': process.returncode == 0
190
+ }
191
+
192
+ # Clean up
193
+ del self.running_processes[process_id]
194
+ return result
195
+
196
+ except Exception as e:
197
+ return {
198
+ 'process_id': process_id,
199
+ 'status': 'error',
200
+ 'error': str(e),
201
+ 'finished': True,
202
+ 'success': False
203
+ }
204
+
205
+ async def _kill_process(self, process_id: str, force: bool = False) -> Dict[str, Any]:
206
+ """Kill a running process"""
207
+
208
+ if process_id not in self.running_processes:
209
+ raise ValueError(f"Process ID not found: {process_id}")
210
+
211
+ process_info = self.running_processes[process_id]
212
+ process = process_info['process']
213
+
214
+ try:
215
+ if force:
216
+ process.kill()
217
+ else:
218
+ process.terminate()
219
+
220
+ # Wait for process to finish
221
+ await asyncio.wait_for(process.wait(), timeout=5.0)
222
+
223
+ # Clean up
224
+ del self.running_processes[process_id]
225
+
226
+ return {
227
+ 'process_id': process_id,
228
+ 'killed': True,
229
+ 'method': 'force' if force else 'terminate'
230
+ }
231
+
232
+ except asyncio.TimeoutError:
233
+ # Force kill if terminate didn't work
234
+ process.kill()
235
+ await process.wait()
236
+ del self.running_processes[process_id]
237
+
238
+ return {
239
+ 'process_id': process_id,
240
+ 'killed': True,
241
+ 'method': 'force (after timeout)'
242
+ }
243
+ except Exception as e:
244
+ return {
245
+ 'process_id': process_id,
246
+ 'killed': False,
247
+ 'error': str(e)
248
+ }
249
+
250
+ async def _check_process_status(self, process_id: str = None) -> Dict[str, Any]:
251
+ """Check status of running processes"""
252
+
253
+ if process_id:
254
+ # Check specific process
255
+ if process_id not in self.running_processes:
256
+ return {
257
+ 'process_id': process_id,
258
+ 'found': False
259
+ }
260
+
261
+ process_info = self.running_processes[process_id]
262
+ process = process_info['process']
263
+
264
+ return {
265
+ 'process_id': process_id,
266
+ 'found': True,
267
+ 'command': process_info['command'],
268
+ 'pid': process.pid,
269
+ 'status': 'running' if process.returncode is None else 'finished',
270
+ 'return_code': process.returncode,
271
+ 'started_at': process_info['started_at'],
272
+ 'running_time': asyncio.get_event_loop().time() - process_info['started_at']
273
+ }
274
+ else:
275
+ # Check all processes
276
+ processes = []
277
+ for pid, info in self.running_processes.items():
278
+ process = info['process']
279
+ processes.append({
280
+ 'process_id': pid,
281
+ 'command': info['command'],
282
+ 'pid': process.pid,
283
+ 'status': 'running' if process.returncode is None else 'finished',
284
+ 'return_code': process.returncode,
285
+ 'started_at': info['started_at'],
286
+ 'running_time': asyncio.get_event_loop().time() - info['started_at']
287
+ })
288
+
289
+ return {
290
+ 'total_processes': len(processes),
291
+ 'processes': processes
292
+ }
@@ -0,0 +1,394 @@
1
+ """
2
+ File operations tool for Cognautic CLI
3
+ """
4
+
5
+ import os
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import List, Dict, Any
9
+ import fnmatch
10
+
11
+ from .base import BaseTool, ToolResult, PermissionLevel
12
+
13
+
14
+ class FileOperationsTool(BaseTool):
15
+ """Tool for file and directory operations"""
16
+
17
+ def __init__(self):
18
+ super().__init__(
19
+ name="file_operations",
20
+ description="Read, write, create, delete, and modify files and directories",
21
+ permission_level=PermissionLevel.SAFE_OPERATIONS
22
+ )
23
+
24
+ def get_capabilities(self) -> List[str]:
25
+ return [
26
+ "read_file",
27
+ "read_file_lines",
28
+ "write_file",
29
+ "write_file_lines",
30
+ "create_file",
31
+ "create_directory",
32
+ "delete_file",
33
+ "list_directory",
34
+ "search_files",
35
+ "copy_file",
36
+ "move_file"
37
+ ]
38
+
39
+ async def execute(self, operation: str, **kwargs) -> ToolResult:
40
+ """Execute file operation"""
41
+
42
+ operations = {
43
+ 'read_file': self._read_file,
44
+ 'read_file_lines': self._read_file_lines,
45
+ 'write_file': self._write_file,
46
+ 'write_file_lines': self._write_file_lines,
47
+ 'create_file': self._create_file,
48
+ 'create_directory': self._create_directory,
49
+ 'delete_file': self._delete_file,
50
+ 'list_directory': self._list_directory,
51
+ 'search_files': self._search_files,
52
+ 'copy_file': self._copy_file,
53
+ 'move_file': self._move_file
54
+ }
55
+
56
+ if operation not in operations:
57
+ return ToolResult(
58
+ success=False,
59
+ error=f"Unknown operation: {operation}"
60
+ )
61
+
62
+ try:
63
+ result = await operations[operation](**kwargs)
64
+ return ToolResult(success=True, data=result)
65
+ except Exception as e:
66
+ return ToolResult(success=False, error=str(e))
67
+
68
+ async def _read_file(self, file_path: str, encoding: str = 'utf-8') -> Dict[str, Any]:
69
+ """Read content from a file"""
70
+ path = Path(file_path)
71
+ if not path.exists():
72
+ raise FileNotFoundError(f"File not found: {file_path}")
73
+
74
+ if not path.is_file():
75
+ raise ValueError(f"Path is not a file: {file_path}")
76
+
77
+ with open(path, 'r', encoding=encoding) as f:
78
+ content = f.read()
79
+
80
+ return {
81
+ 'content': content,
82
+ 'file_path': str(path),
83
+ 'size': len(content)
84
+ }
85
+
86
+ async def _read_file_lines(self, file_path: str, start_line: int = 1, end_line: int = None, encoding: str = 'utf-8') -> Dict[str, Any]:
87
+ """Read specific lines from a file (1-indexed)"""
88
+ path = Path(file_path)
89
+ if not path.exists():
90
+ raise FileNotFoundError(f"File not found: {file_path}")
91
+
92
+ if not path.is_file():
93
+ raise ValueError(f"Path is not a file: {file_path}")
94
+
95
+ with open(path, 'r', encoding=encoding) as f:
96
+ all_lines = f.readlines()
97
+
98
+ total_lines = len(all_lines)
99
+
100
+ # Validate line numbers (1-indexed)
101
+ if start_line < 1:
102
+ start_line = 1
103
+ if end_line is None or end_line > total_lines:
104
+ end_line = total_lines
105
+
106
+ # Convert to 0-indexed for slicing
107
+ start_idx = start_line - 1
108
+ end_idx = end_line
109
+
110
+ selected_lines = all_lines[start_idx:end_idx]
111
+ content = ''.join(selected_lines)
112
+
113
+ return {
114
+ 'content': content,
115
+ 'file_path': str(path),
116
+ 'start_line': start_line,
117
+ 'end_line': end_line,
118
+ 'total_lines': total_lines,
119
+ 'lines_read': len(selected_lines)
120
+ }
121
+
122
+ async def _write_file(
123
+ self,
124
+ file_path: str,
125
+ content: str,
126
+ encoding: str = 'utf-8',
127
+ create_dirs: bool = True,
128
+ append: bool = False
129
+ ) -> Dict[str, Any]:
130
+ """Write content to a file (supports large files)"""
131
+ path = Path(file_path)
132
+ existed_before = path.exists()
133
+
134
+ if create_dirs:
135
+ path.parent.mkdir(parents=True, exist_ok=True)
136
+
137
+ # Handle large content by writing in chunks
138
+ mode = 'a' if append else 'w'
139
+ with open(path, mode, encoding=encoding) as f:
140
+ # Write in chunks to handle large files efficiently
141
+ chunk_size = 1024 * 1024 # 1MB chunks
142
+ for i in range(0, len(content), chunk_size):
143
+ f.write(content[i:i + chunk_size])
144
+
145
+ return {
146
+ 'file_path': str(path),
147
+ 'size': path.stat().st_size,
148
+ 'created': not existed_before
149
+ }
150
+
151
+ async def _write_file_lines(
152
+ self,
153
+ file_path: str,
154
+ content: str,
155
+ start_line: int = 1,
156
+ end_line: int = None,
157
+ encoding: str = 'utf-8',
158
+ create_dirs: bool = True
159
+ ) -> Dict[str, Any]:
160
+ """Replace specific lines in a file (1-indexed)"""
161
+ path = Path(file_path)
162
+
163
+ if create_dirs:
164
+ path.parent.mkdir(parents=True, exist_ok=True)
165
+
166
+ # Read existing file if it exists
167
+ if path.exists():
168
+ with open(path, 'r', encoding=encoding) as f:
169
+ all_lines = f.readlines()
170
+ else:
171
+ all_lines = []
172
+
173
+ total_lines = len(all_lines)
174
+
175
+ # Validate line numbers (1-indexed)
176
+ if start_line < 1:
177
+ start_line = 1
178
+ if end_line is None:
179
+ end_line = start_line
180
+
181
+ # Convert to 0-indexed for slicing
182
+ start_idx = start_line - 1
183
+ end_idx = end_line
184
+
185
+ # Ensure content ends with newline if it doesn't
186
+ new_content = content
187
+ if new_content and not new_content.endswith('\n'):
188
+ new_content += '\n'
189
+
190
+ # Split new content into lines
191
+ new_lines = new_content.split('\n')
192
+ # Remove empty last line if content ended with newline
193
+ if new_lines and new_lines[-1] == '':
194
+ new_lines.pop()
195
+ # Add newlines back
196
+ new_lines = [line + '\n' for line in new_lines]
197
+
198
+ # If file is empty or we're appending beyond end
199
+ if start_idx >= total_lines:
200
+ # Pad with empty lines if needed
201
+ while len(all_lines) < start_idx:
202
+ all_lines.append('\n')
203
+ all_lines.extend(new_lines)
204
+ else:
205
+ # Replace the specified range
206
+ all_lines[start_idx:end_idx] = new_lines
207
+
208
+ # Write back to file
209
+ with open(path, 'w', encoding=encoding) as f:
210
+ f.writelines(all_lines)
211
+
212
+ return {
213
+ 'file_path': str(path),
214
+ 'start_line': start_line,
215
+ 'end_line': start_line + len(new_lines) - 1,
216
+ 'lines_written': len(new_lines),
217
+ 'total_lines': len(all_lines)
218
+ }
219
+
220
+ async def _create_file(self, file_path: str, content: str = "", encoding: str = 'utf-8') -> Dict[str, Any]:
221
+ """Create a new file with content (alias for write_file)"""
222
+ return await self._write_file(file_path, content, encoding, create_dirs=True)
223
+
224
+ async def _create_directory(self, dir_path: str = None, path: str = None, parents: bool = True) -> Dict[str, Any]:
225
+ """Create a directory (accepts either dir_path or path parameter)"""
226
+ # Accept both dir_path and path for flexibility
227
+ directory_path = dir_path or path
228
+ if not directory_path:
229
+ raise ValueError("Either 'dir_path' or 'path' parameter is required")
230
+
231
+ dir_obj = Path(directory_path)
232
+ dir_obj.mkdir(parents=parents, exist_ok=True)
233
+
234
+ return {
235
+ 'directory_path': str(dir_obj),
236
+ 'created': True
237
+ }
238
+
239
+ async def _delete_file(self, file_path: str) -> Dict[str, Any]:
240
+ """Delete a file or directory"""
241
+ path = Path(file_path)
242
+
243
+ if not path.exists():
244
+ raise FileNotFoundError(f"Path not found: {file_path}")
245
+
246
+ if path.is_file():
247
+ path.unlink()
248
+ return {'deleted': str(path), 'type': 'file'}
249
+ elif path.is_dir():
250
+ shutil.rmtree(path)
251
+ return {'deleted': str(path), 'type': 'directory'}
252
+ else:
253
+ raise ValueError(f"Unknown path type: {file_path}")
254
+
255
+ async def _list_directory(
256
+ self,
257
+ dir_path: str,
258
+ recursive: bool = False,
259
+ include_hidden: bool = False
260
+ ) -> List[Dict[str, Any]]:
261
+ """List contents of a directory"""
262
+ path = Path(dir_path)
263
+
264
+ if not path.exists():
265
+ raise FileNotFoundError(f"Directory not found: {dir_path}")
266
+
267
+ if not path.is_dir():
268
+ raise ValueError(f"Path is not a directory: {dir_path}")
269
+
270
+ items = []
271
+
272
+ if recursive:
273
+ pattern = "**/*"
274
+ else:
275
+ pattern = "*"
276
+
277
+ for item in path.glob(pattern):
278
+ if not include_hidden and item.name.startswith('.'):
279
+ continue
280
+
281
+ item_info = {
282
+ 'name': item.name,
283
+ 'path': str(item),
284
+ 'type': 'directory' if item.is_dir() else 'file',
285
+ 'size': item.stat().st_size if item.is_file() else None,
286
+ 'modified': item.stat().st_mtime
287
+ }
288
+ items.append(item_info)
289
+
290
+ return items
291
+
292
+ async def _search_files(
293
+ self,
294
+ search_path: str,
295
+ pattern: str,
296
+ content_search: str = None,
297
+ case_sensitive: bool = False
298
+ ) -> List[Dict[str, Any]]:
299
+ """Search for files by name pattern and optionally content"""
300
+ path = Path(search_path)
301
+
302
+ if not path.exists():
303
+ raise FileNotFoundError(f"Search path not found: {search_path}")
304
+
305
+ matches = []
306
+
307
+ # Search by filename pattern
308
+ for item in path.rglob("*"):
309
+ if item.is_file():
310
+ if fnmatch.fnmatch(item.name, pattern):
311
+ match_info = {
312
+ 'path': str(item),
313
+ 'name': item.name,
314
+ 'size': item.stat().st_size,
315
+ 'modified': item.stat().st_mtime,
316
+ 'content_matches': []
317
+ }
318
+
319
+ # Search content if specified
320
+ if content_search:
321
+ try:
322
+ with open(item, 'r', encoding='utf-8', errors='ignore') as f:
323
+ content = f.read()
324
+
325
+ search_term = content_search if case_sensitive else content_search.lower()
326
+ search_content = content if case_sensitive else content.lower()
327
+
328
+ if search_term in search_content:
329
+ # Find line numbers with matches
330
+ lines = content.split('\n')
331
+ for i, line in enumerate(lines, 1):
332
+ line_search = line if case_sensitive else line.lower()
333
+ if search_term in line_search:
334
+ match_info['content_matches'].append({
335
+ 'line_number': i,
336
+ 'line_content': line.strip()
337
+ })
338
+ except Exception:
339
+ # Skip files that can't be read
340
+ continue
341
+
342
+ matches.append(match_info)
343
+
344
+ return matches
345
+
346
+ async def _copy_file(self, source: str, destination: str) -> Dict[str, Any]:
347
+ """Copy a file or directory"""
348
+ src_path = Path(source)
349
+ dst_path = Path(destination)
350
+
351
+ if not src_path.exists():
352
+ raise FileNotFoundError(f"Source not found: {source}")
353
+
354
+ # Create destination directory if needed
355
+ if dst_path.suffix: # destination is a file
356
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
357
+ else: # destination is a directory
358
+ dst_path.mkdir(parents=True, exist_ok=True)
359
+
360
+ if src_path.is_file():
361
+ if dst_path.is_dir():
362
+ dst_path = dst_path / src_path.name
363
+ shutil.copy2(src_path, dst_path)
364
+ return {'copied': str(src_path), 'to': str(dst_path), 'type': 'file'}
365
+ elif src_path.is_dir():
366
+ if dst_path.exists() and dst_path.is_dir():
367
+ dst_path = dst_path / src_path.name
368
+ shutil.copytree(src_path, dst_path)
369
+ return {'copied': str(src_path), 'to': str(dst_path), 'type': 'directory'}
370
+ else:
371
+ raise ValueError(f"Unknown source type: {source}")
372
+
373
+ async def _move_file(self, source: str, destination: str) -> Dict[str, Any]:
374
+ """Move a file or directory"""
375
+ src_path = Path(source)
376
+ dst_path = Path(destination)
377
+
378
+ if not src_path.exists():
379
+ raise FileNotFoundError(f"Source not found: {source}")
380
+
381
+ # Create destination directory if needed
382
+ if dst_path.suffix: # destination is a file
383
+ dst_path.parent.mkdir(parents=True, exist_ok=True)
384
+ else: # destination is a directory
385
+ dst_path.mkdir(parents=True, exist_ok=True)
386
+ dst_path = dst_path / src_path.name
387
+
388
+ shutil.move(str(src_path), str(dst_path))
389
+
390
+ return {
391
+ 'moved': str(src_path),
392
+ 'to': str(dst_path),
393
+ 'type': 'directory' if dst_path.is_dir() else 'file'
394
+ }