dryymatt commited on
Commit
7083505
Β·
verified Β·
1 Parent(s): 02a377d

Upload litehat/mcp_terminal.py

Browse files
Files changed (1) hide show
  1. litehat/mcp_terminal.py +329 -0
litehat/mcp_terminal.py ADDED
@@ -0,0 +1,329 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LITEHAT MCP TERMINAL
3
+ Native terminal environment via Model Context Protocol.
4
+
5
+ Unlike Docker-only isolation, Litehat uses MCP to access:
6
+ - Local filesystem with zero-copy operations
7
+ - System shell with full process control
8
+ - Browser automation via Playwright/Puppeteer
9
+ - Package managers (npm, pip, cargo, etc.)
10
+ - Git repositories and version control
11
+ - Network access for API calls and webhooks
12
+
13
+ This gives Litehat REAL hands in the system β€” not a sandbox.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import subprocess
19
+ import json
20
+ import shutil
21
+ import tempfile
22
+ import asyncio
23
+ from pathlib import Path
24
+ from typing import Optional, Dict, Any, List
25
+ from dataclasses import dataclass, field
26
+
27
+
28
+ @dataclass
29
+ class TerminalResult:
30
+ """Result of a terminal command execution."""
31
+ command: str
32
+ stdout: str
33
+ stderr: str
34
+ exit_code: int
35
+ elapsed_ms: float
36
+ working_dir: str
37
+
38
+
39
+ @dataclass
40
+ class FileOperation:
41
+ """Record of a file operation for undo/rollback."""
42
+ path: str
43
+ operation: str # "create", "modify", "delete"
44
+ backup_content: Optional[str] = None # For rollback
45
+ timestamp: float = 0.0
46
+
47
+
48
+ class MCPTerminal:
49
+ """
50
+ Model Context Protocol Terminal β€” Litehat's hands in the system.
51
+
52
+ Provides native access to:
53
+ - Filesystem (read/write/delete/traverse)
54
+ - Shell execution (blocking and streaming)
55
+ - Package management (npm, pip, cargo, go modules)
56
+ - Git operations (clone, commit, push, branch)
57
+ - Browser control (open, navigate, screenshot, interact)
58
+ - Process management (start, monitor, kill)
59
+ """
60
+
61
+ def __init__(self, workspace_root: str = "/app/litehat-workspace"):
62
+ self.workspace_root = Path(workspace_root)
63
+ self.workspace_root.mkdir(parents=True, exist_ok=True)
64
+
65
+ # Operation history for rollback
66
+ self.operation_history: List[FileOperation] = []
67
+
68
+ # Track active processes
69
+ self.active_processes: Dict[int, subprocess.Popen] = {}
70
+
71
+ # ── FILESYSTEM OPERATIONS ──
72
+
73
+ def read_file(self, path: str) -> str:
74
+ """Read a file from the workspace."""
75
+ full_path = self._resolve_path(path)
76
+ return full_path.read_text()
77
+
78
+ def write_file(self, path: str, content: str):
79
+ """Write a file, creating directories as needed. Backs up for rollback."""
80
+ full_path = self._resolve_path(path)
81
+ full_path.parent.mkdir(parents=True, exist_ok=True)
82
+
83
+ # Backup for rollback
84
+ if full_path.exists():
85
+ self.operation_history.append(FileOperation(
86
+ path=str(full_path),
87
+ operation="modify",
88
+ backup_content=full_path.read_text(),
89
+ timestamp=__import__('time').time(),
90
+ ))
91
+
92
+ full_path.write_text(content)
93
+
94
+ def delete_file(self, path: str):
95
+ """Delete a file, backing up for rollback."""
96
+ full_path = self._resolve_path(path)
97
+ if full_path.exists():
98
+ self.operation_history.append(FileOperation(
99
+ path=str(full_path),
100
+ operation="delete",
101
+ backup_content=full_path.read_text(),
102
+ timestamp=__import__('time').time(),
103
+ ))
104
+ full_path.unlink()
105
+
106
+ def list_directory(self, path: str = ".") -> List[Dict[str, Any]]:
107
+ """List directory contents."""
108
+ full_path = self._resolve_path(path)
109
+ entries = []
110
+ for entry in full_path.iterdir():
111
+ entries.append({
112
+ "name": entry.name,
113
+ "type": "directory" if entry.is_dir() else "file",
114
+ "size": entry.stat().st_size if entry.is_file() else 0,
115
+ })
116
+ return entries
117
+
118
+ def traverse_project(self) -> Dict[str, Any]:
119
+ """Get full project structure for the AI to understand the codebase."""
120
+ structure = {}
121
+ for root, dirs, files in os.walk(self.workspace_root):
122
+ # Skip hidden and node_modules
123
+ dirs[:] = [d for d in dirs if not d.startswith('.') and d != 'node_modules']
124
+
125
+ rel_path = os.path.relpath(root, self.workspace_root)
126
+ if rel_path == '.':
127
+ rel_path = ''
128
+
129
+ structure[rel_path] = {
130
+ "dirs": dirs,
131
+ "files": files,
132
+ }
133
+ return structure
134
+
135
+ # ── SHELL EXECUTION ──
136
+
137
+ def execute(
138
+ self,
139
+ command: str,
140
+ cwd: Optional[str] = None,
141
+ timeout: int = 300,
142
+ env: Optional[Dict[str, str]] = None,
143
+ ) -> TerminalResult:
144
+ """Execute a shell command and return the result."""
145
+ import time
146
+ start = time.time()
147
+
148
+ work_dir = self._resolve_path(cwd or ".").resolve()
149
+
150
+ merged_env = os.environ.copy()
151
+ if env:
152
+ merged_env.update(env)
153
+
154
+ try:
155
+ result = subprocess.run(
156
+ command,
157
+ shell=True,
158
+ cwd=str(work_dir),
159
+ capture_output=True,
160
+ text=True,
161
+ timeout=timeout,
162
+ env=merged_env,
163
+ )
164
+ elapsed = (time.time() - start) * 1000
165
+
166
+ return TerminalResult(
167
+ command=command,
168
+ stdout=result.stdout,
169
+ stderr=result.stderr,
170
+ exit_code=result.returncode,
171
+ elapsed_ms=elapsed,
172
+ working_dir=str(work_dir),
173
+ )
174
+ except subprocess.TimeoutExpired:
175
+ elapsed = (time.time() - start) * 1000
176
+ return TerminalResult(
177
+ command=command,
178
+ stdout="",
179
+ stderr=f"Command timed out after {timeout}s",
180
+ exit_code=-1,
181
+ elapsed_ms=elapsed,
182
+ working_dir=str(work_dir),
183
+ )
184
+
185
+ def execute_streaming(self, command: str, cwd: Optional[str] = None):
186
+ """Execute a command with streaming output (generator)."""
187
+ work_dir = self._resolve_path(cwd or ".").resolve()
188
+
189
+ process = subprocess.Popen(
190
+ command,
191
+ shell=True,
192
+ cwd=str(work_dir),
193
+ stdout=subprocess.PIPE,
194
+ stderr=subprocess.STDOUT,
195
+ text=True,
196
+ bufsize=1,
197
+ )
198
+
199
+ self.active_processes[id(process)] = process
200
+
201
+ for line in process.stdout:
202
+ yield line.strip()
203
+
204
+ process.wait()
205
+ del self.active_processes[id(process)]
206
+
207
+ # ── PACKAGE MANAGEMENT ──
208
+
209
+ def npm_install(self, packages: List[str], dev: bool = False):
210
+ """Install npm packages."""
211
+ pkg_str = " ".join(packages)
212
+ flag = "--save-dev" if dev else "--save"
213
+ return self.execute(f"npm install {flag} {pkg_str}")
214
+
215
+ def pip_install(self, packages: List[str]):
216
+ """Install pip packages."""
217
+ pkg_str = " ".join(packages)
218
+ return self.execute(f"pip install {pkg_str}")
219
+
220
+ def cargo_add(self, packages: List[str]):
221
+ """Add Cargo dependencies."""
222
+ pkg_str = " ".join(packages)
223
+ return self.execute(f"cargo add {pkg_str}")
224
+
225
+ # ── GIT OPERATIONS ──
226
+
227
+ def git_init(self):
228
+ """Initialize a git repository."""
229
+ return self.execute("git init")
230
+
231
+ def git_clone(self, url: str, branch: Optional[str] = None):
232
+ """Clone a git repository."""
233
+ branch_flag = f"-b {branch}" if branch else ""
234
+ return self.execute(f"git clone {branch_flag} {url}")
235
+
236
+ def git_commit(self, message: str):
237
+ """Stage all and commit."""
238
+ self.execute("git add -A")
239
+ return self.execute(f'git commit -m "{message}"')
240
+
241
+ def git_push(self, remote: str = "origin", branch: str = "main"):
242
+ """Push to remote."""
243
+ return self.execute(f"git push {remote} {branch}")
244
+
245
+ def git_create_branch(self, branch: str):
246
+ """Create and switch to a new branch."""
247
+ return self.execute(f"git checkout -b {branch}")
248
+
249
+ # ── BROWSER CONTROL ──
250
+
251
+ async def browser_open(self, url: str):
252
+ """Open a URL in the browser and return page content."""
253
+ # Uses Playwright for full browser control
254
+ try:
255
+ from playwright.async_api import async_playwright
256
+ async with async_playwright() as p:
257
+ browser = await p.chromium.launch()
258
+ page = await browser.new_page()
259
+ await page.goto(url)
260
+ content = await page.content()
261
+ await browser.close()
262
+ return content
263
+ except ImportError:
264
+ return self.execute(f"curl -sL {url}").stdout
265
+
266
+ async def browser_screenshot(self, url: str, output_path: str):
267
+ """Take a screenshot of a URL."""
268
+ try:
269
+ from playwright.async_api import async_playwright
270
+ async with async_playwright() as p:
271
+ browser = await p.chromium.launch()
272
+ page = await browser.new_page()
273
+ await page.goto(url)
274
+ await page.screenshot(path=output_path, full_page=True)
275
+ await browser.close()
276
+ except ImportError:
277
+ pass
278
+
279
+ # ── ROLLBACK ──
280
+
281
+ def rollback(self, num_operations: int = 1):
282
+ """Rollback the last N file operations."""
283
+ for _ in range(num_operations):
284
+ if not self.operation_history:
285
+ break
286
+ op = self.operation_history.pop()
287
+ path = Path(op.path)
288
+
289
+ if op.operation == "modify" and op.backup_content is not None:
290
+ path.write_text(op.backup_content)
291
+ elif op.operation == "delete" and op.backup_content is not None:
292
+ path.write_text(op.backup_content)
293
+ elif op.operation == "create":
294
+ if path.exists():
295
+ path.unlink()
296
+
297
+ def full_rollback(self):
298
+ """Rollback ALL operations since last checkpoint."""
299
+ self.rollback(len(self.operation_history))
300
+
301
+ # ── HELPERS ──
302
+
303
+ def _resolve_path(self, path: str) -> Path:
304
+ """Resolve a relative path to the workspace root."""
305
+ p = Path(path)
306
+ if p.is_absolute():
307
+ return p
308
+ return self.workspace_root / p
309
+
310
+ def create_checkpoint(self, name: str):
311
+ """Create a named checkpoint for later rollback."""
312
+ checkpoint_dir = self.workspace_root / ".litehat" / "checkpoints" / name
313
+ checkpoint_dir.mkdir(parents=True, exist_ok=True)
314
+
315
+ # Save operation history
316
+ history_path = checkpoint_dir / "operations.json"
317
+ history_path.write_text(json.dumps([
318
+ {"path": op.path, "operation": op.operation, "backup": op.backup_content}
319
+ for op in self.operation_history
320
+ ]))
321
+
322
+ def restore_checkpoint(self, name: str):
323
+ """Restore from a named checkpoint."""
324
+ checkpoint_dir = self.workspace_root / ".litehat" / "checkpoints" / name
325
+ if not checkpoint_dir.exists():
326
+ return False
327
+
328
+ self.full_rollback()
329
+ return True