Files

510 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Smalltalk CLI for Hanzo Bot
Communicates with a Squeak/Cuis MCP server via stdio JSON-RPC.
Requires xvfb-run for headless operation on Linux servers.
Usage:
smalltalk.py --check # Verify setup
smalltalk.py evaluate "3 factorial"
smalltalk.py browse OrderedCollection
smalltalk.py method-source String asUppercase
Environment Variables:
SQUEAK_VM_PATH - Path to Squeak/Cuis VM executable
SQUEAK_IMAGE_PATH - Path to Smalltalk image with MCP server
Author: Adapted from ClaudeSmalltalk by John M McIntosh
"""
import glob
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Optional, Tuple
# Search paths for auto-detection
VM_SEARCH_PATTERNS = [
"~/Squeak*/bin/squeak",
"~/squeak/bin/squeak",
"/usr/local/bin/squeak",
"/usr/bin/squeak",
"/opt/squeak/bin/squeak",
"~/Cuis*/bin/squeak",
]
IMAGE_SEARCH_PATTERNS = [
"~/ClaudeSqueak*.image",
"~/squeak/ClaudeSqueak*.image",
"~/ClaudeCuis*.image",
"~/*Squeak*/*Claude*.image",
]
def find_file(patterns: list[str]) -> Optional[str]:
"""Find first matching file from glob patterns."""
for pattern in patterns:
expanded = os.path.expanduser(pattern)
matches = glob.glob(expanded)
if matches:
return sorted(matches)[-1] # Return newest/latest
return None
def get_paths() -> Tuple[str, str]:
"""Get VM and image paths from env vars or auto-detect."""
vm_path = os.environ.get("SQUEAK_VM_PATH")
image_path = os.environ.get("SQUEAK_IMAGE_PATH")
if not vm_path:
vm_path = find_file(VM_SEARCH_PATTERNS)
if not image_path:
image_path = find_file(IMAGE_SEARCH_PATTERNS)
return vm_path or "", image_path or ""
def check_setup() -> bool:
"""Verify all dependencies and paths are correct."""
print("🔍 Checking Hanzo Bot Smalltalk setup...\n")
all_ok = True
# Check xvfb-run
if shutil.which("xvfb-run"):
print("✅ xvfb-run found")
else:
print("❌ xvfb-run not found - install with: sudo apt install xvfb")
all_ok = False
# Check paths
vm_path, image_path = get_paths()
if vm_path and os.path.exists(vm_path):
print(f"✅ VM found: {vm_path}")
else:
print(f"❌ VM not found")
print(f" Set SQUEAK_VM_PATH or install Squeak 6.0")
print(f" Download from: https://squeak.org/downloads/")
all_ok = False
if image_path and os.path.exists(image_path):
print(f"✅ Image found: {image_path}")
else:
print(f"❌ Image not found")
print(f" Set SQUEAK_IMAGE_PATH or build per SQUEAK-SETUP.md")
all_ok = False
# Check sources file
if image_path and os.path.exists(image_path):
image_dir = os.path.dirname(image_path) or "."
sources = glob.glob(os.path.join(image_dir, "*.sources"))
if sources:
print(f"✅ Sources file found: {sources[0]}")
else:
print(f"⚠️ No .sources file in image directory")
print(f" May cause dialog popups - symlink SqueakV60.sources to {image_dir}/")
print()
if all_ok:
print("✅ Setup looks good!")
else:
print("❌ Setup incomplete - see errors above")
return all_ok
class MCPClient:
"""Simple MCP client for Smalltalk interaction."""
def __init__(self, vm_path: str, image_path: str):
self.vm_path = vm_path
self.image_path = image_path
self.process: Optional[subprocess.Popen] = None
self._request_id = 0
def start(self) -> None:
"""Start the Smalltalk MCP server subprocess."""
if self.process is not None:
return
# Use xvfb-run for headless operation
cmd = ["xvfb-run", "-a", self.vm_path, self.image_path, "--mcp"]
self.process = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1,
)
# Initialize MCP connection
self._initialize()
def stop(self) -> None:
"""Stop the subprocess."""
if self.process is not None:
self.process.terminate()
try:
self.process.wait(timeout=5)
except subprocess.TimeoutExpired:
self.process.kill()
self.process = None
def _next_id(self) -> int:
self._request_id += 1
return self._request_id
def _send(self, method: str, params: Optional[dict] = None) -> dict:
"""Send JSON-RPC request and get response."""
if self.process is None:
raise RuntimeError("MCP server not started")
request = {
"jsonrpc": "2.0",
"id": self._next_id(),
"method": method,
}
if params is not None:
request["params"] = params
self.process.stdin.write(json.dumps(request) + "\n")
self.process.stdin.flush()
# Read response, skipping non-JSON lines (stderr warnings etc)
while True:
response_line = self.process.stdout.readline()
if not response_line:
raise RuntimeError("No response from MCP server")
response_line = response_line.strip()
if response_line.startswith("{"):
return json.loads(response_line)
def _initialize(self) -> None:
"""Initialize the MCP connection."""
response = self._send("initialize", {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "bot-smalltalk", "version": "1.0.0"}
})
if "error" in response:
raise RuntimeError(f"MCP init failed: {response['error']}")
# Send initialized notification
self.process.stdin.write(json.dumps({
"jsonrpc": "2.0",
"method": "notifications/initialized"
}) + "\n")
self.process.stdin.flush()
def call_tool(self, tool_name: str, arguments: dict) -> str:
"""Call an MCP tool and return the result."""
response = self._send("tools/call", {
"name": tool_name,
"arguments": arguments
})
if "error" in response:
return f"Error: {response['error'].get('message', 'Unknown error')}"
result = response.get("result", {})
content = result.get("content", [])
if content and isinstance(content, list):
return content[0].get("text", str(result))
return str(result)
def debug_squeak():
"""Start Squeak, send SIGUSR1, capture stack trace, screenshot, and generate HTML report."""
import signal
import time
import platform
import base64
from datetime import datetime
vm_path, image_path = get_paths()
if not vm_path or not image_path:
print("Error: VM or image not found. Run --check first.")
return False
print("🔍 Starting Squeak for debugging...")
# Start Xvfb
xvfb = subprocess.Popen(
["Xvfb", ":98", "-screen", "0", "1024x768x24"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
time.sleep(2)
# Start Squeak
env = os.environ.copy()
env["DISPLAY"] = ":98"
squeak = subprocess.Popen(
[vm_path, image_path, "--mcp"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
env=env, text=True
)
print(f"⏳ Waiting for Squeak to start (PID {squeak.pid})...")
time.sleep(5)
# Capture screenshot on Linux
screenshot_path = None
screenshot_b64 = None
if platform.system() == "Linux" and shutil.which("import"):
screenshot_path = "/tmp/squeak_debug.png"
subprocess.run(
["import", "-window", "root", "-display", ":98", screenshot_path],
capture_output=True, timeout=10
)
if os.path.exists(screenshot_path):
print(f"📸 Screenshot captured")
with open(screenshot_path, 'rb') as f:
screenshot_b64 = base64.b64encode(f.read()).decode()
else:
print("⚠️ Screenshot capture failed")
screenshot_path = None
print(f"📡 Sending SIGUSR1 to get stack trace...")
squeak.send_signal(signal.SIGUSR1)
time.sleep(2)
# Kill and collect output
squeak.terminate()
try:
output, _ = squeak.communicate(timeout=3)
except subprocess.TimeoutExpired:
squeak.kill()
output, _ = squeak.communicate()
xvfb.terminate()
# Filter out pthread warning boilerplate
skip_patterns = [
'pthread_setschedparam',
'heartbeat thread',
'higher priority',
'security/limits',
'squeak mailing',
'log out and log',
'opensmalltalk-vm',
'cat <<END',
'rtprio',
]
lines = output.split('\n')
filtered = []
for line in lines:
if not any(p in line for p in skip_patterns):
filtered.append(line)
trace_text = '\n'.join(filtered)
# Generate timestamp and report filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
report_path = f"/tmp/ClaudeSmalltalkDebug_{timestamp}.html"
# Generate HTML report
img_html = ""
if screenshot_b64:
img_html = f'<img src="data:image/png;base64,{screenshot_b64}" style="max-width:100%; border:1px solid #ccc;"/>'
html = f'''<!DOCTYPE html>
<html>
<head>
<title>ClaudeSmalltalk Debug Report - {timestamp}</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, monospace; margin: 20px; background: #f5f5f5; }}
.container {{ max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
h1 {{ color: #333; border-bottom: 2px solid #007acc; padding-bottom: 10px; }}
h2 {{ color: #555; margin-top: 30px; }}
pre {{ background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 4px; overflow-x: auto; font-size: 11px; line-height: 1.4; }}
.timestamp {{ color: #888; font-size: 12px; }}
img {{ margin: 10px 0; }}
</style>
</head>
<body>
<div class="container">
<h1>🔧 ClaudeSmalltalk Debug Report</h1>
<p class="timestamp">Generated: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}</p>
<h2>📸 Screenshot</h2>
{img_html if img_html else "<p>Screenshot not available</p>"}
<h2>📋 SIGUSR1 Stack Trace</h2>
<pre>{trace_text}</pre>
</div>
</body>
</html>'''
with open(report_path, 'w') as f:
f.write(html)
print("\n📋 Full stack trace:")
print(trace_text)
print(f"\n📄 Report saved: {report_path}")
return True
def print_usage():
print("Usage: smalltalk.py <command> [args...]")
print("\nCommands:")
print(" --check - Verify setup")
print(" --debug - Debug hung system (SIGUSR1 stack trace)")
print(" evaluate <code> - Evaluate Smalltalk code")
print(" browse <className> - Browse a class")
print(" method-source <class> <sel> - Get method source")
print(" define-class <definition> - Define a class")
print(" define-method <class> <src> - Define a method")
print(" delete-method <class> <sel> - Delete a method")
print(" delete-class <className> - Delete a class")
print(" list-classes [prefix] - List classes")
print(" hierarchy <className> - Get class hierarchy")
print(" subclasses <className> - Get subclasses")
print(" list-categories - List categories")
print(" classes-in-category <cat> - List classes in category")
print("\nEnvironment:")
print(" SQUEAK_VM_PATH - Path to VM (auto-detected if not set)")
print(" SQUEAK_IMAGE_PATH - Path to image (auto-detected if not set)")
def main():
if len(sys.argv) < 2:
print_usage()
sys.exit(1)
command = sys.argv[1]
# Handle --check separately
if command in ("--check", "-c", "check"):
success = check_setup()
sys.exit(0 if success else 1)
# Handle --debug
if command in ("--debug", "-d", "debug"):
success = debug_squeak()
sys.exit(0 if success else 1)
# Get paths
vm_path, image_path = get_paths()
if not vm_path or not os.path.exists(vm_path):
print(f"Error: VM not found")
print(f"Run 'smalltalk.py --check' for setup help")
sys.exit(1)
if not image_path or not os.path.exists(image_path):
print(f"Error: Image not found")
print(f"Run 'smalltalk.py --check' for setup help")
sys.exit(1)
client = MCPClient(vm_path, image_path)
try:
client.start()
# Map commands to tool calls
if command == "evaluate":
if len(sys.argv) < 3:
print("Usage: smalltalk.py evaluate <code>")
sys.exit(1)
code = " ".join(sys.argv[2:])
result = client.call_tool("smalltalk_evaluate", {"code": code})
elif command == "browse":
if len(sys.argv) < 3:
print("Usage: smalltalk.py browse <className>")
sys.exit(1)
result = client.call_tool("smalltalk_browse", {"className": sys.argv[2]})
elif command == "method-source":
if len(sys.argv) < 4:
print("Usage: smalltalk.py method-source <className> <selector>")
sys.exit(1)
result = client.call_tool("smalltalk_method_source", {
"className": sys.argv[2],
"selector": sys.argv[3]
})
elif command == "define-class":
if len(sys.argv) < 3:
print("Usage: smalltalk.py define-class <definition>")
sys.exit(1)
result = client.call_tool("smalltalk_define_class", {
"definition": " ".join(sys.argv[2:])
})
elif command == "define-method":
if len(sys.argv) < 4:
print("Usage: smalltalk.py define-method <className> <source>")
sys.exit(1)
result = client.call_tool("smalltalk_define_method", {
"className": sys.argv[2],
"source": " ".join(sys.argv[3:])
})
elif command == "delete-method":
if len(sys.argv) < 4:
print("Usage: smalltalk.py delete-method <className> <selector>")
sys.exit(1)
result = client.call_tool("smalltalk_delete_method", {
"className": sys.argv[2],
"selector": sys.argv[3]
})
elif command == "delete-class":
if len(sys.argv) < 3:
print("Usage: smalltalk.py delete-class <className>")
sys.exit(1)
result = client.call_tool("smalltalk_delete_class", {"className": sys.argv[2]})
elif command == "list-classes":
prefix = sys.argv[2] if len(sys.argv) > 2 else ""
result = client.call_tool("smalltalk_list_classes", {"prefix": prefix})
elif command == "hierarchy":
if len(sys.argv) < 3:
print("Usage: smalltalk.py hierarchy <className>")
sys.exit(1)
result = client.call_tool("smalltalk_hierarchy", {"className": sys.argv[2]})
elif command == "subclasses":
if len(sys.argv) < 3:
print("Usage: smalltalk.py subclasses <className>")
sys.exit(1)
result = client.call_tool("smalltalk_subclasses", {"className": sys.argv[2]})
elif command == "list-categories":
result = client.call_tool("smalltalk_list_categories", {})
elif command == "classes-in-category":
if len(sys.argv) < 3:
print("Usage: smalltalk.py classes-in-category <category>")
sys.exit(1)
result = client.call_tool("smalltalk_classes_in_category", {
"category": sys.argv[2]
})
else:
print(f"Unknown command: {command}")
print_usage()
sys.exit(1)
print(result)
finally:
client.stop()
if __name__ == "__main__":
main()