delimit-cli 3.14.27 → 3.14.29
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.
- package/bin/delimit-setup.js +19 -2
- package/gateway/ai/backends/deploy_bridge.py +56 -2
- package/gateway/ai/backends/gateway_core.py +212 -1
- package/gateway/ai/backends/generate_bridge.py +84 -13
- package/gateway/ai/backends/governance_bridge.py +63 -16
- package/gateway/ai/backends/memory_bridge.py +77 -76
- package/gateway/ai/backends/ops_bridge.py +76 -6
- package/gateway/ai/backends/os_bridge.py +23 -3
- package/gateway/ai/backends/repo_bridge.py +156 -17
- package/gateway/ai/backends/tools_design.py +116 -9
- package/gateway/ai/backends/tools_infra.py +200 -72
- package/gateway/ai/backends/tools_real.py +8 -0
- package/gateway/ai/backends/ui_bridge.py +115 -5
- package/gateway/ai/backends/vault_bridge.py +69 -114
- package/gateway/ai/content_engine.py +1276 -0
- package/gateway/ai/context_fs.py +193 -0
- package/gateway/ai/daemon.py +500 -0
- package/gateway/ai/data_plane.py +291 -0
- package/gateway/ai/deliberation.py +1033 -6
- package/gateway/ai/events.py +39 -0
- package/gateway/ai/founding_users.py +162 -0
- package/gateway/ai/governance.py +698 -4
- package/gateway/ai/inbox_daemon.py +78 -17
- package/gateway/ai/integrations/__init__.py +1 -0
- package/gateway/ai/integrations/opensage_wrapper.py +288 -0
- package/gateway/ai/key_resolver.py +95 -0
- package/gateway/ai/ledger_manager.py +289 -1
- package/gateway/ai/license.py +62 -4
- package/gateway/ai/license_core.py +208 -7
- package/gateway/ai/local_server.py +215 -0
- package/gateway/ai/loop_engine.py +408 -0
- package/gateway/ai/mcp_bridge.py +178 -0
- package/gateway/ai/release_sync.py +2 -2
- package/gateway/ai/screen_record.py +374 -0
- package/gateway/ai/secrets_broker.py +235 -0
- package/gateway/ai/social.py +189 -27
- package/gateway/ai/social_target.py +1635 -0
- package/gateway/ai/supabase_sync.py +190 -0
- package/gateway/ai/tracing.py +195 -0
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/diff_engine_v2.py +272 -78
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/gateway/core/policy_engine.py +4 -0
- package/package.json +1 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Delimit MCP Bridge Relay Server.
|
|
3
|
+
|
|
4
|
+
Bridges HTTP requests to a local MCP server running on stdin/stdout.
|
|
5
|
+
Designed for remote Claude Code sessions to proxy tool calls via SSH tunnel.
|
|
6
|
+
|
|
7
|
+
Usage: python mcp_bridge.py --command "path/to/mcp-server" --port 7824
|
|
8
|
+
|
|
9
|
+
Endpoints:
|
|
10
|
+
GET /health - Health check
|
|
11
|
+
GET /mcp/list - List available tools from the local MCP server
|
|
12
|
+
POST /mcp/call - Call a tool: {"method":"tools/call","params":{"name":"...","arguments":{...}}}
|
|
13
|
+
"""
|
|
14
|
+
import argparse, json, logging, select, subprocess, threading
|
|
15
|
+
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("delimit.mcp_bridge")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MCPSubprocessClient:
|
|
21
|
+
"""Manages a local MCP server subprocess communicating via JSON-RPC over stdio."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, command: str, init_timeout: float = 10.0):
|
|
24
|
+
self.command = command
|
|
25
|
+
self.init_timeout = init_timeout
|
|
26
|
+
self._proc = None
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._request_id = 0
|
|
29
|
+
|
|
30
|
+
def start(self):
|
|
31
|
+
self._proc = subprocess.Popen(
|
|
32
|
+
self.command, shell=True,
|
|
33
|
+
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
34
|
+
)
|
|
35
|
+
init_resp = self._send_request("initialize", {
|
|
36
|
+
"protocolVersion": "2024-11-05", "capabilities": {},
|
|
37
|
+
"clientInfo": {"name": "delimit-bridge", "version": "1.0.0"},
|
|
38
|
+
})
|
|
39
|
+
if init_resp is None:
|
|
40
|
+
raise RuntimeError("MCP server did not respond to initialize")
|
|
41
|
+
self._send_notification("notifications/initialized", {})
|
|
42
|
+
logger.info("MCP server initialized: %s", init_resp.get("result", {}).get("serverInfo", {}))
|
|
43
|
+
|
|
44
|
+
def stop(self):
|
|
45
|
+
if self._proc and self._proc.poll() is None:
|
|
46
|
+
self._proc.terminate()
|
|
47
|
+
try:
|
|
48
|
+
self._proc.wait(timeout=5)
|
|
49
|
+
except subprocess.TimeoutExpired:
|
|
50
|
+
self._proc.kill()
|
|
51
|
+
|
|
52
|
+
def list_tools(self) -> dict:
|
|
53
|
+
return self._send_request("tools/list", {})
|
|
54
|
+
|
|
55
|
+
def call_tool(self, name: str, arguments: dict) -> dict:
|
|
56
|
+
return self._send_request("tools/call", {"name": name, "arguments": arguments})
|
|
57
|
+
|
|
58
|
+
def _next_id(self) -> int:
|
|
59
|
+
self._request_id += 1
|
|
60
|
+
return self._request_id
|
|
61
|
+
|
|
62
|
+
def _send_notification(self, method: str, params: dict):
|
|
63
|
+
self._write({"jsonrpc": "2.0", "method": method, "params": params})
|
|
64
|
+
|
|
65
|
+
def _send_request(self, method: str, params: dict, timeout: float = 30.0):
|
|
66
|
+
with self._lock:
|
|
67
|
+
req_id = self._next_id()
|
|
68
|
+
self._write({"jsonrpc": "2.0", "id": req_id, "method": method, "params": params})
|
|
69
|
+
return self._read(timeout=timeout)
|
|
70
|
+
|
|
71
|
+
def _write(self, msg: dict):
|
|
72
|
+
if self._proc is None or self._proc.stdin is None:
|
|
73
|
+
raise RuntimeError("MCP subprocess not running")
|
|
74
|
+
self._proc.stdin.write((json.dumps(msg) + "\n").encode())
|
|
75
|
+
self._proc.stdin.flush()
|
|
76
|
+
|
|
77
|
+
def _read(self, timeout: float = 30.0):
|
|
78
|
+
if self._proc is None or self._proc.stdout is None:
|
|
79
|
+
return None
|
|
80
|
+
ready, _, _ = select.select([self._proc.stdout], [], [], timeout)
|
|
81
|
+
if not ready:
|
|
82
|
+
logger.warning("Timeout waiting for MCP response")
|
|
83
|
+
return None
|
|
84
|
+
line = self._proc.stdout.readline()
|
|
85
|
+
return json.loads(line.decode().strip()) if line else None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class BridgeHandler(BaseHTTPRequestHandler):
|
|
89
|
+
"""HTTP handler that proxies requests to the MCP subprocess."""
|
|
90
|
+
server_version = "DelimitMCPBridge/1.0"
|
|
91
|
+
mcp_client: MCPSubprocessClient = None
|
|
92
|
+
|
|
93
|
+
def log_message(self, fmt, *args):
|
|
94
|
+
logger.info(fmt, *args)
|
|
95
|
+
|
|
96
|
+
def _json(self, data: dict, status: int = 200):
|
|
97
|
+
body = json.dumps(data).encode()
|
|
98
|
+
self.send_response(status)
|
|
99
|
+
self.send_header("Content-Type", "application/json")
|
|
100
|
+
self.send_header("Content-Length", str(len(body)))
|
|
101
|
+
self.end_headers()
|
|
102
|
+
self.wfile.write(body)
|
|
103
|
+
|
|
104
|
+
def _body(self) -> bytes:
|
|
105
|
+
return self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
106
|
+
|
|
107
|
+
def _mcp_result(self, resp, on_result=None):
|
|
108
|
+
"""Unwrap an MCP JSON-RPC response into an HTTP response."""
|
|
109
|
+
if resp and "result" in resp:
|
|
110
|
+
self._json(on_result(resp["result"]) if on_result else {"result": resp["result"]})
|
|
111
|
+
elif resp and "error" in resp:
|
|
112
|
+
self._json({"error": resp["error"]}, 502)
|
|
113
|
+
else:
|
|
114
|
+
self._json({"error": "no_response_from_mcp_server"}, 502)
|
|
115
|
+
|
|
116
|
+
def do_GET(self):
|
|
117
|
+
if self.path == "/health":
|
|
118
|
+
self._json({"status": "ok", "server": "delimit-mcp-bridge"})
|
|
119
|
+
elif self.path == "/mcp/list":
|
|
120
|
+
try:
|
|
121
|
+
self._mcp_result(self.mcp_client.list_tools(),
|
|
122
|
+
on_result=lambda r: {"tools": r.get("tools", [])})
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
logger.exception("list_tools failed")
|
|
125
|
+
self._json({"error": str(exc)}, 500)
|
|
126
|
+
else:
|
|
127
|
+
self._json({"error": "not_found"}, 404)
|
|
128
|
+
|
|
129
|
+
def do_POST(self):
|
|
130
|
+
if self.path != "/mcp/call":
|
|
131
|
+
self._json({"error": "not_found"}, 404)
|
|
132
|
+
return
|
|
133
|
+
try:
|
|
134
|
+
raw = self._body()
|
|
135
|
+
if not raw:
|
|
136
|
+
self._json({"error": "empty_body"}, 400); return
|
|
137
|
+
body = json.loads(raw)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
self._json({"error": "invalid_json"}, 400); return
|
|
140
|
+
params = body.get("params", {})
|
|
141
|
+
tool_name = params.get("name")
|
|
142
|
+
if not tool_name:
|
|
143
|
+
self._json({"error": "missing params.name"}, 400); return
|
|
144
|
+
try:
|
|
145
|
+
self._mcp_result(self.mcp_client.call_tool(tool_name, params.get("arguments", {})))
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
logger.exception("call_tool failed")
|
|
148
|
+
self._json({"error": str(exc)}, 500)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def create_server(command: str, port: int = 7824, init_timeout: float = 10.0):
|
|
152
|
+
"""Create and return (httpd, mcp_client) without starting the serve loop."""
|
|
153
|
+
client = MCPSubprocessClient(command, init_timeout=init_timeout)
|
|
154
|
+
client.start()
|
|
155
|
+
handler = type("Handler", (BridgeHandler,), {"mcp_client": client})
|
|
156
|
+
return HTTPServer(("127.0.0.1", port), handler), client
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main():
|
|
160
|
+
parser = argparse.ArgumentParser(description="Delimit MCP Bridge Relay")
|
|
161
|
+
parser.add_argument("--command", required=True, help="Command to spawn the local MCP server")
|
|
162
|
+
parser.add_argument("--port", type=int, default=7824, help="Port to listen on (default 7824)")
|
|
163
|
+
args = parser.parse_args()
|
|
164
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
|
165
|
+
logger.info("Starting MCP bridge: command=%r port=%d", args.command, args.port)
|
|
166
|
+
httpd, client = create_server(args.command, args.port)
|
|
167
|
+
try:
|
|
168
|
+
logger.info("Bridge listening on 127.0.0.1:%d", args.port)
|
|
169
|
+
httpd.serve_forever()
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
logger.info("Shutting down")
|
|
172
|
+
finally:
|
|
173
|
+
httpd.server_close()
|
|
174
|
+
client.stop()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
main()
|
|
@@ -28,10 +28,10 @@ DEFAULT_CONFIG = {
|
|
|
28
28
|
"urls": {
|
|
29
29
|
"homepage": "https://delimit.ai",
|
|
30
30
|
"docs": "https://delimit.ai/docs",
|
|
31
|
-
"github": "https://github.com/delimit-ai/delimit",
|
|
31
|
+
"github": "https://github.com/delimit-ai/delimit-mcp-server",
|
|
32
32
|
"action": "https://github.com/marketplace/actions/delimit-api-governance",
|
|
33
33
|
"npm": "https://www.npmjs.com/package/delimit-cli",
|
|
34
|
-
"quickstart": "https://github.com/delimit-ai/delimit-quickstart",
|
|
34
|
+
"quickstart": "https://github.com/delimit-ai/delimit-mcp-server-quickstart",
|
|
35
35
|
},
|
|
36
36
|
}
|
|
37
37
|
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Screen recording helpers for Delimit MCP.
|
|
3
|
+
|
|
4
|
+
Two modes:
|
|
5
|
+
- browser: Xvfb + Chromium + ffmpeg -> MP4
|
|
6
|
+
- terminal: asciinema + agg + ffmpeg -> .cast + GIF + MP4
|
|
7
|
+
|
|
8
|
+
Bonus:
|
|
9
|
+
- take_screenshot: Chromium headless screenshot -> PNG
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import time
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Dict, Optional
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("delimit.ai.screen_record")
|
|
22
|
+
|
|
23
|
+
# ── Constants ────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
CHROMIUM_PATH = "/root/.cache/puppeteer/chrome/linux-146.0.7680.153/chrome-linux64/chrome"
|
|
26
|
+
CONTENT_BASE = Path.home() / ".delimit" / "content"
|
|
27
|
+
VIDEOS_DIR = CONTENT_BASE / "videos"
|
|
28
|
+
GIFS_DIR = CONTENT_BASE / "gifs"
|
|
29
|
+
CASTS_DIR = CONTENT_BASE / "casts"
|
|
30
|
+
SCREENSHOTS_DIR = CONTENT_BASE / "screenshots"
|
|
31
|
+
|
|
32
|
+
MAX_DURATION = 120
|
|
33
|
+
DEFAULT_WIDTH = 1920
|
|
34
|
+
DEFAULT_HEIGHT = 1080
|
|
35
|
+
DISPLAY_NUM = 99
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ensure_dirs():
|
|
39
|
+
"""Create output directories if they don't exist."""
|
|
40
|
+
for d in (VIDEOS_DIR, GIFS_DIR, CASTS_DIR, SCREENSHOTS_DIR):
|
|
41
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_binary(name: str) -> Optional[str]:
|
|
45
|
+
"""Return path to binary or None if not found."""
|
|
46
|
+
return shutil.which(name)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _file_size_kb(path: Path) -> int:
|
|
50
|
+
"""Return file size in KB, or 0 if missing."""
|
|
51
|
+
try:
|
|
52
|
+
return int(path.stat().st_size / 1024)
|
|
53
|
+
except (OSError, FileNotFoundError):
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _kill_pid(pid: int):
|
|
58
|
+
"""Kill a process, ignoring errors."""
|
|
59
|
+
try:
|
|
60
|
+
os.kill(pid, signal.SIGTERM)
|
|
61
|
+
except (OSError, ProcessLookupError):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ── Browser Recording ────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
def record_browser(url: str, name: str, duration: int) -> Dict[str, Any]:
|
|
68
|
+
"""Record a Chromium browser session visiting a URL as 1080p MP4.
|
|
69
|
+
|
|
70
|
+
Starts Xvfb virtual display, ffmpeg screen capture, and Chromium.
|
|
71
|
+
Cleans up all processes on completion or failure.
|
|
72
|
+
"""
|
|
73
|
+
# Dependency checks
|
|
74
|
+
missing = []
|
|
75
|
+
if not _check_binary("Xvfb"):
|
|
76
|
+
missing.append("Xvfb (apt install xvfb)")
|
|
77
|
+
if not _check_binary("ffmpeg"):
|
|
78
|
+
missing.append("ffmpeg (apt install ffmpeg)")
|
|
79
|
+
if not Path(CHROMIUM_PATH).exists():
|
|
80
|
+
missing.append(f"Chromium at {CHROMIUM_PATH}")
|
|
81
|
+
if missing:
|
|
82
|
+
return {
|
|
83
|
+
"recorded": False,
|
|
84
|
+
"error": "missing_dependencies",
|
|
85
|
+
"message": f"Required binaries not found: {', '.join(missing)}",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if not url:
|
|
89
|
+
return {"recorded": False, "error": "missing_url", "message": "url is required for browser mode"}
|
|
90
|
+
|
|
91
|
+
duration = min(max(1, duration), MAX_DURATION)
|
|
92
|
+
_ensure_dirs()
|
|
93
|
+
|
|
94
|
+
output_path = VIDEOS_DIR / f"{name}.mp4"
|
|
95
|
+
display = f":{DISPLAY_NUM}"
|
|
96
|
+
|
|
97
|
+
xvfb_proc = None
|
|
98
|
+
ffmpeg_proc = None
|
|
99
|
+
chrome_proc = None
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# 1. Start virtual display
|
|
103
|
+
xvfb_proc = subprocess.Popen(
|
|
104
|
+
["Xvfb", display, "-screen", "0", f"{DEFAULT_WIDTH}x{DEFAULT_HEIGHT}x24"],
|
|
105
|
+
stdout=subprocess.DEVNULL,
|
|
106
|
+
stderr=subprocess.DEVNULL,
|
|
107
|
+
)
|
|
108
|
+
time.sleep(1)
|
|
109
|
+
|
|
110
|
+
env = os.environ.copy()
|
|
111
|
+
env["DISPLAY"] = display
|
|
112
|
+
|
|
113
|
+
# 2. Start ffmpeg recording
|
|
114
|
+
ffmpeg_proc = subprocess.Popen(
|
|
115
|
+
[
|
|
116
|
+
"ffmpeg", "-y",
|
|
117
|
+
"-video_size", f"{DEFAULT_WIDTH}x{DEFAULT_HEIGHT}",
|
|
118
|
+
"-framerate", "30",
|
|
119
|
+
"-f", "x11grab", "-i", display,
|
|
120
|
+
"-t", str(duration),
|
|
121
|
+
"-c:v", "libx264", "-preset", "ultrafast", "-crf", "23",
|
|
122
|
+
"-pix_fmt", "yuv420p",
|
|
123
|
+
str(output_path),
|
|
124
|
+
],
|
|
125
|
+
stdout=subprocess.DEVNULL,
|
|
126
|
+
stderr=subprocess.PIPE,
|
|
127
|
+
env=env,
|
|
128
|
+
)
|
|
129
|
+
time.sleep(1)
|
|
130
|
+
|
|
131
|
+
# 3. Launch Chromium
|
|
132
|
+
chrome_proc = subprocess.Popen(
|
|
133
|
+
[
|
|
134
|
+
CHROMIUM_PATH,
|
|
135
|
+
"--no-sandbox",
|
|
136
|
+
"--disable-gpu",
|
|
137
|
+
"--disable-dev-shm-usage",
|
|
138
|
+
f"--window-size={DEFAULT_WIDTH},{DEFAULT_HEIGHT}",
|
|
139
|
+
"--start-maximized",
|
|
140
|
+
url,
|
|
141
|
+
],
|
|
142
|
+
stdout=subprocess.DEVNULL,
|
|
143
|
+
stderr=subprocess.DEVNULL,
|
|
144
|
+
env=env,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# 4. Wait for ffmpeg to finish (it will stop after -t duration)
|
|
148
|
+
ffmpeg_proc.wait(timeout=duration + 30)
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"recorded": True,
|
|
152
|
+
"mode": "browser",
|
|
153
|
+
"files": {
|
|
154
|
+
"mp4": str(output_path),
|
|
155
|
+
},
|
|
156
|
+
"duration": duration,
|
|
157
|
+
"size_kb": _file_size_kb(output_path),
|
|
158
|
+
"url": url,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
except subprocess.TimeoutExpired:
|
|
162
|
+
if ffmpeg_proc:
|
|
163
|
+
ffmpeg_proc.kill()
|
|
164
|
+
return {
|
|
165
|
+
"recorded": False,
|
|
166
|
+
"error": "timeout",
|
|
167
|
+
"message": f"Recording timed out after {duration + 30}s",
|
|
168
|
+
}
|
|
169
|
+
except Exception as e:
|
|
170
|
+
logger.error("Browser recording failed: %s", e)
|
|
171
|
+
return {
|
|
172
|
+
"recorded": False,
|
|
173
|
+
"error": "recording_failed",
|
|
174
|
+
"message": str(e),
|
|
175
|
+
}
|
|
176
|
+
finally:
|
|
177
|
+
# Always clean up processes
|
|
178
|
+
if chrome_proc and chrome_proc.poll() is None:
|
|
179
|
+
_kill_pid(chrome_proc.pid)
|
|
180
|
+
if ffmpeg_proc and ffmpeg_proc.poll() is None:
|
|
181
|
+
_kill_pid(ffmpeg_proc.pid)
|
|
182
|
+
if xvfb_proc and xvfb_proc.poll() is None:
|
|
183
|
+
_kill_pid(xvfb_proc.pid)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Terminal Recording ───────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
def record_terminal(name: str, duration: int, script: str = "") -> Dict[str, Any]:
|
|
189
|
+
"""Record a terminal session as .cast + GIF + MP4.
|
|
190
|
+
|
|
191
|
+
Uses asciinema to record, agg to convert to GIF, ffmpeg for MP4.
|
|
192
|
+
If script is provided, it runs non-interactively via asciinema rec -c.
|
|
193
|
+
"""
|
|
194
|
+
missing = []
|
|
195
|
+
if not _check_binary("asciinema"):
|
|
196
|
+
missing.append("asciinema (pip install asciinema)")
|
|
197
|
+
if not _check_binary("agg"):
|
|
198
|
+
missing.append("agg (cargo install agg)")
|
|
199
|
+
if not _check_binary("ffmpeg"):
|
|
200
|
+
missing.append("ffmpeg (apt install ffmpeg)")
|
|
201
|
+
if missing:
|
|
202
|
+
return {
|
|
203
|
+
"recorded": False,
|
|
204
|
+
"error": "missing_dependencies",
|
|
205
|
+
"message": f"Required binaries not found: {', '.join(missing)}",
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
duration = min(max(1, duration), MAX_DURATION)
|
|
209
|
+
_ensure_dirs()
|
|
210
|
+
|
|
211
|
+
cast_path = CASTS_DIR / f"{name}.cast"
|
|
212
|
+
gif_path = GIFS_DIR / f"{name}.gif"
|
|
213
|
+
mp4_path = VIDEOS_DIR / f"{name}.mp4"
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
# 1. Record with asciinema
|
|
217
|
+
asciinema_cmd = [
|
|
218
|
+
"asciinema", "rec", str(cast_path),
|
|
219
|
+
"--overwrite", "--cols", "120", "--rows", "35",
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
if script:
|
|
223
|
+
# Run a command non-interactively
|
|
224
|
+
asciinema_cmd.extend(["-c", script])
|
|
225
|
+
result = subprocess.run(
|
|
226
|
+
asciinema_cmd,
|
|
227
|
+
timeout=duration + 10,
|
|
228
|
+
capture_output=True,
|
|
229
|
+
text=True,
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
# Record idle terminal for specified duration
|
|
233
|
+
# Use a simple sleep command to fill the duration
|
|
234
|
+
asciinema_cmd.extend(["-c", f"sleep {duration}"])
|
|
235
|
+
result = subprocess.run(
|
|
236
|
+
asciinema_cmd,
|
|
237
|
+
timeout=duration + 10,
|
|
238
|
+
capture_output=True,
|
|
239
|
+
text=True,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if not cast_path.exists():
|
|
243
|
+
return {
|
|
244
|
+
"recorded": False,
|
|
245
|
+
"error": "asciinema_failed",
|
|
246
|
+
"message": f"asciinema did not produce output: {result.stderr}",
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
files = {"cast": str(cast_path)}
|
|
250
|
+
|
|
251
|
+
# 2. Convert to GIF via agg
|
|
252
|
+
agg_result = subprocess.run(
|
|
253
|
+
[
|
|
254
|
+
"agg", str(cast_path), str(gif_path),
|
|
255
|
+
"--font-size", "16",
|
|
256
|
+
"--theme", "monokai",
|
|
257
|
+
"--speed", "1.5",
|
|
258
|
+
"--cols", "120",
|
|
259
|
+
"--rows", "35",
|
|
260
|
+
],
|
|
261
|
+
timeout=60,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
)
|
|
265
|
+
if gif_path.exists():
|
|
266
|
+
files["gif"] = str(gif_path)
|
|
267
|
+
|
|
268
|
+
# 3. Convert GIF to MP4 via ffmpeg
|
|
269
|
+
if gif_path.exists():
|
|
270
|
+
ffmpeg_result = subprocess.run(
|
|
271
|
+
[
|
|
272
|
+
"ffmpeg", "-y", "-i", str(gif_path),
|
|
273
|
+
"-movflags", "faststart",
|
|
274
|
+
"-pix_fmt", "yuv420p",
|
|
275
|
+
"-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2",
|
|
276
|
+
str(mp4_path),
|
|
277
|
+
],
|
|
278
|
+
timeout=60,
|
|
279
|
+
capture_output=True,
|
|
280
|
+
text=True,
|
|
281
|
+
)
|
|
282
|
+
if mp4_path.exists():
|
|
283
|
+
files["mp4"] = str(mp4_path)
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"recorded": True,
|
|
287
|
+
"mode": "terminal",
|
|
288
|
+
"files": files,
|
|
289
|
+
"duration": duration,
|
|
290
|
+
"size_kb": _file_size_kb(mp4_path) or _file_size_kb(gif_path) or _file_size_kb(cast_path),
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
except subprocess.TimeoutExpired:
|
|
294
|
+
return {
|
|
295
|
+
"recorded": False,
|
|
296
|
+
"error": "timeout",
|
|
297
|
+
"message": f"Terminal recording timed out after {duration + 10}s",
|
|
298
|
+
}
|
|
299
|
+
except Exception as e:
|
|
300
|
+
logger.error("Terminal recording failed: %s", e)
|
|
301
|
+
return {
|
|
302
|
+
"recorded": False,
|
|
303
|
+
"error": "recording_failed",
|
|
304
|
+
"message": str(e),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# ── Screenshot ───────────────────────────────────────────────────────────
|
|
309
|
+
|
|
310
|
+
def take_screenshot(url: str, name: str) -> Dict[str, Any]:
|
|
311
|
+
"""Take a single screenshot of a URL using headless Chromium.
|
|
312
|
+
|
|
313
|
+
Returns PNG path. Useful for audit evidence and visual regression.
|
|
314
|
+
"""
|
|
315
|
+
if not Path(CHROMIUM_PATH).exists():
|
|
316
|
+
return {
|
|
317
|
+
"recorded": False,
|
|
318
|
+
"error": "missing_dependencies",
|
|
319
|
+
"message": f"Chromium not found at {CHROMIUM_PATH}",
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if not url:
|
|
323
|
+
return {"recorded": False, "error": "missing_url", "message": "url is required for screenshot"}
|
|
324
|
+
|
|
325
|
+
_ensure_dirs()
|
|
326
|
+
output_path = SCREENSHOTS_DIR / f"{name}.png"
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
result = subprocess.run(
|
|
330
|
+
[
|
|
331
|
+
CHROMIUM_PATH,
|
|
332
|
+
"--headless",
|
|
333
|
+
"--no-sandbox",
|
|
334
|
+
"--disable-gpu",
|
|
335
|
+
"--disable-dev-shm-usage",
|
|
336
|
+
f"--window-size={DEFAULT_WIDTH},{DEFAULT_HEIGHT}",
|
|
337
|
+
f"--screenshot={output_path}",
|
|
338
|
+
url,
|
|
339
|
+
],
|
|
340
|
+
timeout=30,
|
|
341
|
+
capture_output=True,
|
|
342
|
+
text=True,
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
if output_path.exists():
|
|
346
|
+
return {
|
|
347
|
+
"recorded": True,
|
|
348
|
+
"mode": "screenshot",
|
|
349
|
+
"files": {
|
|
350
|
+
"png": str(output_path),
|
|
351
|
+
},
|
|
352
|
+
"size_kb": _file_size_kb(output_path),
|
|
353
|
+
"url": url,
|
|
354
|
+
}
|
|
355
|
+
else:
|
|
356
|
+
return {
|
|
357
|
+
"recorded": False,
|
|
358
|
+
"error": "screenshot_failed",
|
|
359
|
+
"message": f"Chromium did not produce screenshot: {result.stderr[:500]}",
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
except subprocess.TimeoutExpired:
|
|
363
|
+
return {
|
|
364
|
+
"recorded": False,
|
|
365
|
+
"error": "timeout",
|
|
366
|
+
"message": "Screenshot timed out after 30s",
|
|
367
|
+
}
|
|
368
|
+
except Exception as e:
|
|
369
|
+
logger.error("Screenshot failed: %s", e)
|
|
370
|
+
return {
|
|
371
|
+
"recorded": False,
|
|
372
|
+
"error": "screenshot_failed",
|
|
373
|
+
"message": str(e),
|
|
374
|
+
}
|