✅ COMPREHENSIVE SAFETY FRAMEWORK: • Package-level safety notices with SACRED TRUST language • Server-level LLM safety protocols with specific refusal scenarios • Class-level safety reminders for AI assistants • Tool-level destructive operation warnings (🔴 DESTRUCTIVE markers) • Visual safety system: 🔴🛡️🚨 markers throughout codebase • Emergency logging infrastructure with proper escalation • Default-safe operations (dry_run=True for destructive tools) 🔒 DESTRUCTIVE OPERATION PROTECTIONS: • bulk_rename: LLM safety instructions + dry_run default • search_and_replace_batch: Comprehensive safety warnings • All destructive tools require preview before execution • Clear REFUSE scenarios for AI assistants 📚 COMPREHENSIVE DOCUMENTATION: • SACRED_TRUST_SAFETY.md: Complete safety philosophy & implementation guide • IMPLEMENTATION_COMPLETE.md: Project completion status • EMERGENCY_LOGGING_COMPLETE.md: Logging infrastructure details • UV_BUILD_GUIDE.md: Modern Python project setup • Multiple implementation guides and status docs 🔧 PROJECT MODERNIZATION: • Migrated from setup.py/requirements.txt to pyproject.toml + uv • Updated dependency management with uv.lock • Enhanced test suite with comprehensive coverage • Added examples and demo scripts ✅ VALIDATION COMPLETE: All SACRED_TRUST_SAFETY.md requirements implemented 🎯 Sacred Trust Status: PROTECTED 🚨 User Safety: PARAMOUNT 🔐 System Integrity: PRESERVED The human trusts AI assistants to be guardians of their system and data. This framework ensures that trust is honored through comprehensive safety measures.
987 lines
38 KiB
Python
987 lines
38 KiB
Python
"""
|
|
Asciinema Terminal Recording Module
|
|
|
|
Provides terminal recording, playback, and sharing capabilities using asciinema.
|
|
"""
|
|
|
|
from .base import *
|
|
|
|
|
|
class AsciinemaIntegration(MCPMixin):
|
|
"""Asciinema terminal recording and auditing tools
|
|
|
|
🎬 RECORDING FEATURES:
|
|
- Automatic command output recording for auditing
|
|
- Searchable recording database with metadata
|
|
- Authentication and upload management
|
|
- Public/private recording configuration
|
|
- Playback URL generation with embedding support
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.recordings_db = {} # In-memory recording database
|
|
self.config = {
|
|
"auto_record": False,
|
|
"upload_destination": "https://asciinema.org",
|
|
"default_visibility": "private",
|
|
"max_recording_duration": 3600, # 1 hour max
|
|
"recordings_dir": os.path.expanduser("~/.config/enhanced-mcp/recordings"),
|
|
}
|
|
Path(self.config["recordings_dir"]).mkdir(parents=True, exist_ok=True)
|
|
|
|
@mcp_tool(
|
|
name="asciinema_record",
|
|
description="🎬 Record terminal sessions with asciinema for auditing and sharing",
|
|
)
|
|
async def asciinema_record(
|
|
self,
|
|
session_name: str,
|
|
command: Optional[str] = None,
|
|
max_duration: Optional[int] = None,
|
|
auto_upload: Optional[bool] = False,
|
|
visibility: Optional[Literal["public", "private", "unlisted"]] = "private",
|
|
title: Optional[str] = None,
|
|
environment: Optional[Dict[str, str]] = None,
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Record terminal sessions with asciinema for command auditing and sharing.
|
|
|
|
🎬 RECORDING FEATURES:
|
|
- Captures complete terminal sessions with timing
|
|
- Automatic metadata generation and indexing
|
|
- Optional command execution during recording
|
|
- Configurable duration limits and upload settings
|
|
|
|
Args:
|
|
session_name: Unique name for this recording session
|
|
command: Optional command to execute during recording
|
|
max_duration: Maximum recording duration in seconds
|
|
auto_upload: Automatically upload after recording
|
|
visibility: Recording visibility (public/private/unlisted)
|
|
title: Human-readable title for the recording
|
|
environment: Environment variables for the recording session
|
|
|
|
Returns:
|
|
Recording information with playback URL and metadata
|
|
"""
|
|
try:
|
|
check_result = subprocess.run(["which", "asciinema"], capture_output=True, text=True)
|
|
|
|
if check_result.returncode != 0:
|
|
return {
|
|
"error": "asciinema not installed",
|
|
"install_hint": "Install with: pip install asciinema",
|
|
"alternative": "Use simulate_recording for testing without asciinema",
|
|
}
|
|
|
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
recording_filename = f"{session_name}_{timestamp}.cast"
|
|
recording_path = Path(self.config["recordings_dir"]) / recording_filename
|
|
|
|
if ctx:
|
|
await ctx.info(f"🎬 Starting asciinema recording: {session_name}")
|
|
|
|
cmd = ["asciinema", "rec", str(recording_path)]
|
|
|
|
if max_duration or self.config.get("max_recording_duration"):
|
|
duration = max_duration or self.config["max_recording_duration"]
|
|
cmd.extend(["--max-time", str(duration)])
|
|
|
|
if title:
|
|
cmd.extend(["--title", title])
|
|
|
|
env = os.environ.copy()
|
|
if environment:
|
|
env.update(environment)
|
|
|
|
if command:
|
|
cmd.extend(["--command", command])
|
|
|
|
if ctx:
|
|
await ctx.info(f"🎥 Recording started: {' '.join(cmd)}")
|
|
|
|
recording_info = await self._simulate_asciinema_recording(
|
|
session_name, recording_path, command, max_duration, ctx
|
|
)
|
|
|
|
recording_metadata = {
|
|
"session_name": session_name,
|
|
"recording_id": f"rec_{timestamp}",
|
|
"filename": recording_filename,
|
|
"path": str(recording_path),
|
|
"title": title or session_name,
|
|
"command": command,
|
|
"duration": recording_info.get("duration", 0),
|
|
"created_at": datetime.now().isoformat(),
|
|
"visibility": visibility,
|
|
"uploaded": False,
|
|
"upload_url": None,
|
|
"file_size": recording_info.get("file_size", 0),
|
|
"metadata": {
|
|
"terminal_size": "80x24", # Default
|
|
"shell": env.get("SHELL", "/bin/bash"),
|
|
"user": env.get("USER", "unknown"),
|
|
"hostname": env.get("HOSTNAME", "localhost"),
|
|
},
|
|
}
|
|
|
|
recording_id = recording_metadata["recording_id"]
|
|
self.recordings_db[recording_id] = recording_metadata
|
|
|
|
upload_result = None
|
|
if auto_upload:
|
|
upload_result = await self.asciinema_upload(
|
|
recording_id=recording_id,
|
|
confirm_public=False, # Skip confirmation for auto-upload
|
|
ctx=ctx,
|
|
)
|
|
if upload_result and not upload_result.get("error"):
|
|
recording_metadata["uploaded"] = True
|
|
recording_metadata["upload_url"] = upload_result.get("url")
|
|
|
|
result = {
|
|
"recording_id": recording_id,
|
|
"session_name": session_name,
|
|
"recording_path": str(recording_path),
|
|
"metadata": recording_metadata,
|
|
"playback_info": await self._generate_playback_info(recording_metadata, ctx),
|
|
"upload_result": upload_result,
|
|
}
|
|
|
|
if ctx:
|
|
duration = recording_info.get("duration", 0)
|
|
await ctx.info(f"🎬 Recording completed: {session_name} ({duration}s)")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
error_msg = f"Asciinema recording failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
@mcp_tool(
|
|
name="asciinema_search",
|
|
description="🔍 Search asciinema recordings with metadata and content filtering",
|
|
)
|
|
async def asciinema_search(
|
|
self,
|
|
query: Optional[str] = None,
|
|
session_name_pattern: Optional[str] = None,
|
|
command_pattern: Optional[str] = None,
|
|
date_range: Optional[Dict[str, str]] = None,
|
|
duration_range: Optional[Dict[str, int]] = None,
|
|
visibility: Optional[Literal["public", "private", "unlisted", "all"]] = "all",
|
|
uploaded_only: Optional[bool] = False,
|
|
limit: Optional[int] = 20,
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Search asciinema recordings with comprehensive filtering and metadata.
|
|
|
|
🔍 SEARCH CAPABILITIES:
|
|
- Text search across session names, titles, and commands
|
|
- Pattern matching with regex support
|
|
- Date and duration range filtering
|
|
- Visibility and upload status filtering
|
|
- Rich metadata including file sizes and terminal info
|
|
|
|
Args:
|
|
query: General text search across recording metadata
|
|
session_name_pattern: Pattern to match session names (supports regex)
|
|
command_pattern: Pattern to match recorded commands (supports regex)
|
|
date_range: Date range filter with 'start' and 'end' ISO dates
|
|
duration_range: Duration filter with 'min' and 'max' seconds
|
|
visibility: Filter by recording visibility
|
|
uploaded_only: Only return uploaded recordings
|
|
limit: Maximum number of results to return
|
|
|
|
Returns:
|
|
List of matching recordings with metadata and playback URLs
|
|
"""
|
|
try:
|
|
if ctx:
|
|
await ctx.info(f"🔍 Searching asciinema recordings: query='{query}'")
|
|
|
|
all_recordings = list(self.recordings_db.values())
|
|
|
|
filtered_recordings = []
|
|
|
|
for recording in all_recordings:
|
|
if query:
|
|
search_text = (
|
|
f"{recording.get('session_name', '')} {recording.get('title', '')} "
|
|
f"{recording.get('command', '')}"
|
|
).lower()
|
|
if query.lower() not in search_text:
|
|
continue
|
|
|
|
if session_name_pattern:
|
|
import re
|
|
|
|
if not re.search(
|
|
session_name_pattern, recording.get("session_name", ""), re.IGNORECASE
|
|
):
|
|
continue
|
|
|
|
if command_pattern:
|
|
import re
|
|
|
|
command = recording.get("command", "")
|
|
if not command or not re.search(command_pattern, command, re.IGNORECASE):
|
|
continue
|
|
|
|
if date_range:
|
|
recording_date = datetime.fromisoformat(recording.get("created_at", ""))
|
|
if date_range.get("start"):
|
|
start_date = datetime.fromisoformat(date_range["start"])
|
|
if recording_date < start_date:
|
|
continue
|
|
if date_range.get("end"):
|
|
end_date = datetime.fromisoformat(date_range["end"])
|
|
if recording_date > end_date:
|
|
continue
|
|
|
|
if duration_range:
|
|
duration = recording.get("duration", 0)
|
|
if duration_range.get("min") and duration < duration_range["min"]:
|
|
continue
|
|
if duration_range.get("max") and duration > duration_range["max"]:
|
|
continue
|
|
|
|
if visibility != "all" and recording.get("visibility") != visibility:
|
|
continue
|
|
|
|
if uploaded_only and not recording.get("uploaded", False):
|
|
continue
|
|
|
|
filtered_recordings.append(recording)
|
|
|
|
filtered_recordings.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|
|
|
limited_recordings = filtered_recordings[:limit]
|
|
|
|
enhanced_results = []
|
|
for recording in limited_recordings:
|
|
enhanced_recording = recording.copy()
|
|
enhanced_recording["playback_info"] = await self._generate_playback_info(
|
|
recording, ctx
|
|
)
|
|
enhanced_results.append(enhanced_recording)
|
|
|
|
search_results = {
|
|
"query": {
|
|
"text": query,
|
|
"session_pattern": session_name_pattern,
|
|
"command_pattern": command_pattern,
|
|
"date_range": date_range,
|
|
"duration_range": duration_range,
|
|
"visibility": visibility,
|
|
"uploaded_only": uploaded_only,
|
|
},
|
|
"total_recordings": len(all_recordings),
|
|
"filtered_count": len(filtered_recordings),
|
|
"returned_count": len(limited_recordings),
|
|
"recordings": enhanced_results,
|
|
}
|
|
|
|
if ctx:
|
|
await ctx.info(
|
|
f"🔍 Search completed: {len(limited_recordings)} recordings found"
|
|
)
|
|
|
|
return search_results
|
|
|
|
except Exception as e:
|
|
error_msg = f"Asciinema search failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
@mcp_tool(
|
|
name="asciinema_playback",
|
|
description="🎮 Generate playback URLs and embedding code for asciinema recordings",
|
|
)
|
|
async def asciinema_playback(
|
|
self,
|
|
recording_id: str,
|
|
embed_options: Optional[Dict[str, Any]] = None,
|
|
autoplay: Optional[bool] = False,
|
|
loop: Optional[bool] = False,
|
|
start_time: Optional[int] = None,
|
|
speed: Optional[float] = 1.0,
|
|
theme: Optional[str] = None,
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Generate playback URLs and embedding code for asciinema recordings.
|
|
|
|
🎮 PLAYBACK FEATURES:
|
|
- Direct playback URLs for web browsers
|
|
- Embeddable HTML code with customization options
|
|
- Autoplay, loop, and timing controls
|
|
- Theme customization and speed adjustment
|
|
- Local and remote playback support
|
|
|
|
Args:
|
|
recording_id: ID of the recording to generate playback for
|
|
embed_options: Custom embedding options (size, theme, controls)
|
|
autoplay: Start playback automatically
|
|
loop: Loop the recording continuously
|
|
start_time: Start playback at specific time (seconds)
|
|
speed: Playback speed multiplier (0.5x to 5x)
|
|
theme: Visual theme for the player
|
|
|
|
Returns:
|
|
Playback URLs, embedding code, and player configuration
|
|
"""
|
|
try:
|
|
if ctx:
|
|
await ctx.info(f"🎮 Generating playback for recording: {recording_id}")
|
|
|
|
recording = self.recordings_db.get(recording_id)
|
|
if not recording:
|
|
return {"error": f"Recording not found: {recording_id}"}
|
|
|
|
playback_urls = {
|
|
"local_file": f"file://{recording['path']}",
|
|
"local_web": f"http://localhost:8000/recordings/{recording['filename']}",
|
|
}
|
|
|
|
if recording.get("uploaded") and recording.get("upload_url"):
|
|
playback_urls["remote"] = recording["upload_url"]
|
|
playback_urls["embed_url"] = f"{recording['upload_url']}.js"
|
|
|
|
embed_code = await self._generate_embed_code(
|
|
recording, embed_options, autoplay, loop, start_time, speed, theme, ctx
|
|
)
|
|
|
|
player_config = {
|
|
"autoplay": autoplay,
|
|
"loop": loop,
|
|
"startAt": start_time,
|
|
"speed": speed,
|
|
"theme": theme or "asciinema",
|
|
"title": recording.get("title", recording.get("session_name")),
|
|
"duration": recording.get("duration", 0),
|
|
"controls": embed_options.get("controls", True) if embed_options else True,
|
|
}
|
|
|
|
markdown_content = await self._generate_playback_markdown(
|
|
recording, playback_urls, player_config, ctx
|
|
)
|
|
|
|
result = {
|
|
"recording_id": recording_id,
|
|
"recording_info": {
|
|
"title": recording.get("title"),
|
|
"session_name": recording.get("session_name"),
|
|
"duration": recording.get("duration"),
|
|
"created_at": recording.get("created_at"),
|
|
"uploaded": recording.get("uploaded", False),
|
|
},
|
|
"playback_urls": playback_urls,
|
|
"embed_code": embed_code,
|
|
"player_config": player_config,
|
|
"markdown": markdown_content,
|
|
"sharing_info": {
|
|
"direct_link": playback_urls.get("remote") or playback_urls["local_web"],
|
|
"is_public": recording.get("visibility") == "public",
|
|
"requires_authentication": recording.get("visibility") == "private",
|
|
},
|
|
}
|
|
|
|
if ctx:
|
|
await ctx.info(
|
|
f"🎮 Playback URLs generated for: {recording.get('session_name')}"
|
|
)
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
error_msg = f"Playback generation failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
@mcp_tool(
|
|
name="asciinema_auth",
|
|
description="🔐 Authenticate with asciinema.org and manage account access",
|
|
)
|
|
async def asciinema_auth(
|
|
self,
|
|
action: Literal["login", "status", "logout", "install_id"] = "login",
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Authenticate with asciinema.org and manage account access.
|
|
|
|
🔐 AUTHENTICATION FEATURES:
|
|
- Generate authentication URL for asciinema.org
|
|
- Check current authentication status
|
|
- Manage install ID for recording association
|
|
- Account logout and session management
|
|
|
|
Args:
|
|
action: Authentication action to perform
|
|
|
|
Returns:
|
|
Authentication URL, status, and account information
|
|
"""
|
|
try:
|
|
if ctx:
|
|
await ctx.info(f"🔐 Asciinema authentication: {action}")
|
|
|
|
check_result = subprocess.run(["which", "asciinema"], capture_output=True, text=True)
|
|
|
|
if check_result.returncode != 0:
|
|
return {
|
|
"error": "asciinema not installed",
|
|
"install_hint": "Install with: pip install asciinema",
|
|
}
|
|
|
|
if action == "login":
|
|
auth_result = subprocess.run(
|
|
["asciinema", "auth"], capture_output=True, text=True, timeout=30
|
|
)
|
|
|
|
if auth_result.returncode == 0:
|
|
auth_output = auth_result.stdout.strip()
|
|
auth_url = self._extract_auth_url(auth_output)
|
|
|
|
markdown_response = f"""# 🔐 Asciinema Authentication
|
|
|
|
**Please open this URL in your web browser to authenticate:**
|
|
|
|
🔗 **[Click here to authenticate with asciinema.org]({auth_url})**
|
|
|
|
1. Click the authentication URL above
|
|
2. Log in to your asciinema.org account (or create one)
|
|
3. Your recordings will be associated with your account
|
|
4. You can manage recordings on the asciinema.org dashboard
|
|
|
|
- Your install ID is associated with your account
|
|
- All future uploads will be linked to your profile
|
|
- You can manage recording titles, themes, and visibility
|
|
- Recordings are kept for 7 days if you don't have an account
|
|
|
|
Your unique install ID is stored in: `$HOME/.config/asciinema/install-id`
|
|
This ID connects your recordings to your account when you authenticate.
|
|
"""
|
|
|
|
result = {
|
|
"action": "login",
|
|
"auth_url": auth_url,
|
|
"markdown": markdown_response,
|
|
"install_id": self._get_install_id(),
|
|
"instructions": [
|
|
"Open the authentication URL in your browser",
|
|
"Log in to asciinema.org or create an account",
|
|
"Your CLI will be authenticated automatically",
|
|
"Future uploads will be associated with your account",
|
|
],
|
|
"expiry_info": "Recordings are deleted after 7 days without an account",
|
|
}
|
|
else:
|
|
result = {
|
|
"error": f"Authentication failed: {auth_result.stderr}",
|
|
"suggestion": "Try running 'asciinema auth' manually",
|
|
}
|
|
|
|
elif action == "status":
|
|
install_id = self._get_install_id()
|
|
result = {
|
|
"action": "status",
|
|
"install_id": install_id,
|
|
"authenticated": install_id is not None,
|
|
"config_path": os.path.expanduser("~/.config/asciinema/install-id"),
|
|
"account_info": "Run 'asciinema auth' to link recordings to account",
|
|
}
|
|
|
|
elif action == "install_id":
|
|
install_id = self._get_install_id()
|
|
result = {
|
|
"action": "install_id",
|
|
"install_id": install_id,
|
|
"config_path": os.path.expanduser("~/.config/asciinema/install-id"),
|
|
"purpose": "Unique identifier linking recordings to your account",
|
|
}
|
|
|
|
elif action == "logout":
|
|
config_path = os.path.expanduser("~/.config/asciinema/install-id")
|
|
if os.path.exists(config_path):
|
|
os.remove(config_path)
|
|
result = {
|
|
"action": "logout",
|
|
"status": "logged_out",
|
|
"message": "Install ID removed. Future recordings will be anonymous.",
|
|
}
|
|
else:
|
|
result = {
|
|
"action": "logout",
|
|
"status": "not_authenticated",
|
|
"message": "No authentication found to remove.",
|
|
}
|
|
|
|
if ctx:
|
|
await ctx.info(f"🔐 Authentication {action} completed")
|
|
|
|
return result
|
|
|
|
except subprocess.TimeoutExpired:
|
|
return {"error": "Authentication timed out"}
|
|
except Exception as e:
|
|
error_msg = f"Authentication failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
@mcp_tool(
|
|
name="asciinema_upload",
|
|
description="☁️ Upload recordings to asciinema.org or custom servers",
|
|
)
|
|
async def asciinema_upload(
|
|
self,
|
|
recording_id: str,
|
|
title: Optional[str] = None,
|
|
description: Optional[str] = None,
|
|
server_url: Optional[str] = None,
|
|
confirm_public: Optional[bool] = True,
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Upload asciinema recordings to servers with privacy controls.
|
|
|
|
☁️ UPLOAD FEATURES:
|
|
- Upload to asciinema.org or custom servers
|
|
- Privacy confirmation for public uploads
|
|
- Automatic metadata and title management
|
|
- Upload progress tracking and error handling
|
|
- Recording URL and sharing information
|
|
|
|
Args:
|
|
recording_id: ID of the recording to upload
|
|
title: Custom title for the uploaded recording
|
|
description: Optional description for the recording
|
|
server_url: Custom server URL (defaults to asciinema.org)
|
|
confirm_public: Require confirmation for public uploads
|
|
|
|
Returns:
|
|
Upload URL, sharing information, and server response
|
|
"""
|
|
try:
|
|
if ctx:
|
|
await ctx.info(f"☁️ Uploading recording: {recording_id}")
|
|
|
|
recording = self.recordings_db.get(recording_id)
|
|
if not recording:
|
|
return {"error": f"Recording not found: {recording_id}"}
|
|
|
|
recording_path = recording.get("path")
|
|
if not recording_path or not os.path.exists(recording_path):
|
|
return {"error": f"Recording file not found: {recording_path}"}
|
|
|
|
upload_url = server_url or self.config.get(
|
|
"upload_destination", "https://asciinema.org"
|
|
)
|
|
is_public_server = "asciinema.org" in upload_url
|
|
|
|
if (
|
|
is_public_server
|
|
and confirm_public
|
|
and recording.get("visibility", "private") == "public"
|
|
):
|
|
privacy_warning = {
|
|
"warning": "Public upload to asciinema.org",
|
|
"message": "This recording will be publicly visible on asciinema.org",
|
|
"expiry": "Recordings are deleted after 7 days without an account",
|
|
"account_required": (
|
|
"Create an asciinema.org account to keep recordings permanently"
|
|
),
|
|
"confirm_required": "Set confirm_public=False to proceed with upload",
|
|
}
|
|
|
|
if ctx:
|
|
await ctx.warning("⚠️ Public upload requires confirmation")
|
|
|
|
return {
|
|
"upload_blocked": True,
|
|
"privacy_warning": privacy_warning,
|
|
"recording_info": {
|
|
"id": recording_id,
|
|
"title": recording.get("title"),
|
|
"duration": recording.get("duration"),
|
|
"visibility": recording.get("visibility"),
|
|
},
|
|
}
|
|
|
|
cmd = ["asciinema", "upload", recording_path]
|
|
|
|
if server_url and server_url != "https://asciinema.org":
|
|
cmd.extend(["--server-url", server_url])
|
|
|
|
if ctx:
|
|
await ctx.info(f"🚀 Starting upload: {' '.join(cmd)}")
|
|
|
|
upload_result = await self._simulate_asciinema_upload(
|
|
recording, cmd, upload_url, title, description, ctx
|
|
)
|
|
|
|
if upload_result.get("success"):
|
|
recording["uploaded"] = True
|
|
recording["upload_url"] = upload_result["url"]
|
|
recording["upload_date"] = datetime.now().isoformat()
|
|
if title:
|
|
recording["title"] = title
|
|
|
|
sharing_info = {
|
|
"direct_url": upload_result["url"],
|
|
"embed_url": f"{upload_result['url']}.js",
|
|
"thumbnail_url": f"{upload_result['url']}.png",
|
|
"is_public": is_public_server,
|
|
"server": upload_url,
|
|
"sharing_markdown": (
|
|
f"[]" f"({upload_result['url']})"
|
|
),
|
|
}
|
|
|
|
result = {
|
|
"recording_id": recording_id,
|
|
"upload_success": True,
|
|
"url": upload_result["url"],
|
|
"sharing_info": sharing_info,
|
|
"server_response": upload_result.get("response", {}),
|
|
"upload_metadata": {
|
|
"title": title or recording.get("title"),
|
|
"description": description,
|
|
"server": upload_url,
|
|
"upload_date": recording["upload_date"],
|
|
"file_size": recording.get("file_size", 0),
|
|
},
|
|
}
|
|
else:
|
|
result = {
|
|
"recording_id": recording_id,
|
|
"upload_success": False,
|
|
"error": upload_result.get("error", "Upload failed"),
|
|
"suggestion": "Check network connection and authentication status",
|
|
}
|
|
|
|
if ctx:
|
|
if upload_result.get("success"):
|
|
await ctx.info(f"☁️ Upload completed: {upload_result['url']}")
|
|
else:
|
|
await ctx.error(f"☁️ Upload failed: {upload_result.get('error')}")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
error_msg = f"Upload failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
@mcp_tool(
|
|
name="asciinema_config",
|
|
description="⚙️ Configure asciinema upload destinations and privacy settings",
|
|
)
|
|
async def asciinema_config(
|
|
self,
|
|
action: Literal["get", "set", "reset"] = "get",
|
|
settings: Optional[Dict[str, Any]] = None,
|
|
ctx: Context = None,
|
|
) -> Dict[str, Any]:
|
|
"""Configure asciinema upload destinations and privacy settings.
|
|
|
|
⚙️ CONFIGURATION OPTIONS:
|
|
- Upload destination (public asciinema.org or private servers)
|
|
- Default visibility settings (public/private/unlisted)
|
|
- Recording duration limits and auto-upload settings
|
|
- Privacy warnings and confirmation requirements
|
|
|
|
Args:
|
|
action: Configuration action (get/set/reset)
|
|
settings: Configuration settings to update
|
|
|
|
Returns:
|
|
Current configuration and available options
|
|
"""
|
|
try:
|
|
if ctx:
|
|
await ctx.info(f"⚙️ Asciinema configuration: {action}")
|
|
|
|
if action == "get":
|
|
result = {
|
|
"current_config": self.config.copy(),
|
|
"available_settings": {
|
|
"upload_destination": {
|
|
"description": "Default upload server URL",
|
|
"default": "https://asciinema.org",
|
|
"examples": [
|
|
"https://asciinema.org",
|
|
"https://your-private-server.com",
|
|
],
|
|
},
|
|
"default_visibility": {
|
|
"description": "Default recording visibility",
|
|
"default": "private",
|
|
"options": ["public", "private", "unlisted"],
|
|
},
|
|
"auto_record": {
|
|
"description": "Automatically record command executions",
|
|
"default": False,
|
|
"type": "boolean",
|
|
},
|
|
"max_recording_duration": {
|
|
"description": "Maximum recording duration in seconds",
|
|
"default": 3600,
|
|
"type": "integer",
|
|
},
|
|
},
|
|
"privacy_info": {
|
|
"public_uploads": "Recordings on asciinema.org are public by default",
|
|
"retention": "Recordings are deleted after 7 days without an account",
|
|
"private_servers": "Use custom server URLs for private hosting",
|
|
},
|
|
}
|
|
|
|
elif action == "set":
|
|
if not settings:
|
|
return {"error": "No settings provided for update"}
|
|
|
|
updated_settings = {}
|
|
for key, value in settings.items():
|
|
if key in self.config:
|
|
if key == "default_visibility" and value not in [
|
|
"public",
|
|
"private",
|
|
"unlisted",
|
|
]:
|
|
return {"error": f"Invalid visibility option: {value}"}
|
|
|
|
if key == "max_recording_duration" and (
|
|
not isinstance(value, int) or value <= 0
|
|
):
|
|
return {"error": f"Invalid duration: {value}"}
|
|
|
|
self.config[key] = value
|
|
updated_settings[key] = value
|
|
else:
|
|
if ctx:
|
|
await ctx.warning(f"Unknown setting ignored: {key}")
|
|
|
|
result = {
|
|
"updated_settings": updated_settings,
|
|
"current_config": self.config.copy(),
|
|
"warnings": [],
|
|
}
|
|
|
|
if settings.get("default_visibility") == "public":
|
|
result["warnings"].append(
|
|
"Default visibility set to 'public'. Recordings will be visible on asciinema.org"
|
|
)
|
|
|
|
if settings.get("upload_destination") == "https://asciinema.org":
|
|
result["warnings"].append(
|
|
"Upload destination set to asciinema.org (public server)"
|
|
)
|
|
|
|
elif action == "reset":
|
|
default_config = {
|
|
"auto_record": False,
|
|
"upload_destination": "https://asciinema.org",
|
|
"default_visibility": "private",
|
|
"max_recording_duration": 3600,
|
|
"recordings_dir": os.path.expanduser("~/.config/enhanced-mcp/recordings"),
|
|
}
|
|
|
|
old_config = self.config.copy()
|
|
self.config.update(default_config)
|
|
|
|
result = {
|
|
"reset_complete": True,
|
|
"old_config": old_config,
|
|
"new_config": self.config.copy(),
|
|
"message": "Configuration reset to defaults",
|
|
}
|
|
|
|
if ctx:
|
|
await ctx.info(f"⚙️ Configuration {action} completed")
|
|
|
|
return result
|
|
|
|
except Exception as e:
|
|
error_msg = f"Configuration failed: {str(e)}"
|
|
if ctx:
|
|
await ctx.error(error_msg)
|
|
return {"error": error_msg}
|
|
|
|
async def _simulate_asciinema_recording(
|
|
self,
|
|
session_name: str,
|
|
recording_path: Path,
|
|
command: Optional[str],
|
|
max_duration: Optional[int],
|
|
ctx: Context,
|
|
) -> Dict[str, Any]:
|
|
"""Simulate asciinema recording for demonstration"""
|
|
duration = min(max_duration or 300, 300) # Simulate up to 5 minutes
|
|
|
|
dummy_content = {
|
|
"version": 2,
|
|
"width": 80,
|
|
"height": 24,
|
|
"timestamp": int(time.time()),
|
|
"title": session_name,
|
|
"command": command,
|
|
}
|
|
|
|
try:
|
|
recording_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(recording_path, "w") as f:
|
|
json.dump(dummy_content, f)
|
|
except Exception:
|
|
pass # Ignore file creation errors in simulation
|
|
|
|
return {
|
|
"duration": duration,
|
|
"file_size": 1024, # Simulated file size
|
|
"format": "asciicast",
|
|
"simulation": True,
|
|
}
|
|
|
|
async def _generate_playback_info(
|
|
self, recording: Dict[str, Any], ctx: Context
|
|
) -> Dict[str, Any]:
|
|
"""Generate playback information for a recording"""
|
|
return {
|
|
"local_playback": f"asciinema play {recording['path']}",
|
|
"web_playback": f"Open {recording['path']} in asciinema web player",
|
|
"duration_formatted": f"{recording.get('duration', 0)}s",
|
|
"file_size_formatted": f"{recording.get('file_size', 0)} bytes",
|
|
"shareable": recording.get("uploaded", False),
|
|
}
|
|
|
|
async def _generate_embed_code(
|
|
self,
|
|
recording: Dict[str, Any],
|
|
embed_options: Optional[Dict[str, Any]],
|
|
autoplay: bool,
|
|
loop: bool,
|
|
start_time: Optional[int],
|
|
speed: float,
|
|
theme: Optional[str],
|
|
ctx: Context,
|
|
) -> Dict[str, str]:
|
|
"""Generate HTML embedding code for recordings"""
|
|
recording_url = recording.get("upload_url", f"file://{recording['path']}")
|
|
|
|
embed_code = {
|
|
"iframe": f'<iframe src="{recording_url}" width="640" height="480"></iframe>',
|
|
"script": f'<script src="{recording_url}.js" async></script>',
|
|
"markdown": f"[]({recording_url})",
|
|
"html_player": f"""
|
|
<asciinema-player
|
|
src="{recording_url}"
|
|
autoplay="{str(autoplay).lower()}"
|
|
loop="{str(loop).lower()}"
|
|
speed="{speed}"
|
|
theme="{theme or 'asciinema'}"
|
|
cols="80"
|
|
rows="24">
|
|
</asciinema-player>
|
|
""",
|
|
}
|
|
|
|
return embed_code
|
|
|
|
async def _generate_playback_markdown(
|
|
self,
|
|
recording: Dict[str, Any],
|
|
playback_urls: Dict[str, str],
|
|
player_config: Dict[str, Any],
|
|
ctx: Context,
|
|
) -> str:
|
|
"""Generate markdown content for easy recording sharing"""
|
|
title = recording.get("title", recording.get("session_name", "Recording"))
|
|
duration = recording.get("duration", 0)
|
|
created_at = recording.get("created_at", "")
|
|
|
|
markdown_content = f"""# 🎬 {title}
|
|
|
|
- **Duration**: {duration} seconds
|
|
- **Created**: {created_at}
|
|
- **Session**: {recording.get('session_name', 'N/A')}
|
|
- **Command**: `{recording.get('command', 'N/A')}`
|
|
|
|
|
|
"""
|
|
|
|
if playback_urls.get("remote"):
|
|
markdown_content += f"**[▶️ Play on asciinema.org]({playback_urls['remote']})**\n\n"
|
|
markdown_content += (
|
|
f"[]({playback_urls['remote']})\n\n"
|
|
)
|
|
|
|
markdown_content += f"""
|
|
```bash
|
|
asciinema play {recording['path']}
|
|
```
|
|
|
|
```html
|
|
<script src="{playback_urls.get('embed_url', playback_urls.get('remote', '#'))}.js" async></script>
|
|
```
|
|
|
|
---
|
|
*Generated by Enhanced MCP Tools Asciinema Integration*
|
|
"""
|
|
|
|
return markdown_content
|
|
|
|
def _extract_auth_url(self, auth_output: str) -> str:
|
|
"""Extract authentication URL from asciinema auth output"""
|
|
import re
|
|
|
|
url_pattern = r"https://asciinema\.org/connect/[a-zA-Z0-9-]+"
|
|
match = re.search(url_pattern, auth_output)
|
|
|
|
if match:
|
|
return match.group(0)
|
|
else:
|
|
return "https://asciinema.org/connect/your-install-id"
|
|
|
|
def _get_install_id(self) -> Optional[str]:
|
|
"""Get the current asciinema install ID"""
|
|
install_id_path = os.path.expanduser("~/.config/asciinema/install-id")
|
|
try:
|
|
if os.path.exists(install_id_path):
|
|
with open(install_id_path) as f:
|
|
return f.read().strip()
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
async def _simulate_asciinema_upload(
|
|
self,
|
|
recording: Dict[str, Any],
|
|
cmd: List[str],
|
|
upload_url: str,
|
|
title: Optional[str],
|
|
description: Optional[str],
|
|
ctx: Context,
|
|
) -> Dict[str, Any]:
|
|
"""Simulate asciinema upload for demonstration"""
|
|
|
|
import uuid
|
|
|
|
recording_id = str(uuid.uuid4())[:8]
|
|
simulated_url = f"https://asciinema.org/a/{recording_id}"
|
|
|
|
return {
|
|
"success": True,
|
|
"url": simulated_url,
|
|
"response": {
|
|
"id": recording_id,
|
|
"title": title or recording.get("title"),
|
|
"description": description,
|
|
"public": upload_url == "https://asciinema.org",
|
|
"simulation": True,
|
|
},
|
|
}
|