claudemol 0.1.0__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.
- claudemol/__init__.py +32 -0
- claudemol/cli.py +167 -0
- claudemol/connection.py +249 -0
- claudemol/plugin.py +186 -0
- claudemol/session.py +295 -0
- claudemol/view.py +137 -0
- claudemol-0.1.0.dist-info/METADATA +158 -0
- claudemol-0.1.0.dist-info/RECORD +11 -0
- claudemol-0.1.0.dist-info/WHEEL +4 -0
- claudemol-0.1.0.dist-info/entry_points.txt +2 -0
- claudemol-0.1.0.dist-info/licenses/LICENSE +21 -0
claudemol/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
claudemol: PyMOL integration for Claude Code
|
|
3
|
+
|
|
4
|
+
Connect to PyMOL via socket for AI-assisted molecular visualization.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from claudemol.connection import (
|
|
8
|
+
PyMOLConnection,
|
|
9
|
+
connect_or_launch,
|
|
10
|
+
launch_pymol,
|
|
11
|
+
find_pymol_command,
|
|
12
|
+
check_pymol_installed,
|
|
13
|
+
)
|
|
14
|
+
from claudemol.session import (
|
|
15
|
+
PyMOLSession,
|
|
16
|
+
get_session,
|
|
17
|
+
ensure_running,
|
|
18
|
+
stop_pymol,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
__all__ = [
|
|
23
|
+
"PyMOLConnection",
|
|
24
|
+
"PyMOLSession",
|
|
25
|
+
"connect_or_launch",
|
|
26
|
+
"launch_pymol",
|
|
27
|
+
"find_pymol_command",
|
|
28
|
+
"check_pymol_installed",
|
|
29
|
+
"get_session",
|
|
30
|
+
"ensure_running",
|
|
31
|
+
"stop_pymol",
|
|
32
|
+
]
|
claudemol/cli.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI for claudemol setup and management.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
claudemol setup # Configure PyMOL to auto-load the socket plugin
|
|
6
|
+
claudemol status # Check if PyMOL is running and connected
|
|
7
|
+
claudemol test # Test the connection
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from claudemol.connection import (
|
|
15
|
+
PyMOLConnection,
|
|
16
|
+
check_pymol_installed,
|
|
17
|
+
find_pymol_command,
|
|
18
|
+
get_plugin_path,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_pymol():
|
|
23
|
+
"""Configure PyMOL to auto-load the socket plugin."""
|
|
24
|
+
plugin_path = get_plugin_path()
|
|
25
|
+
if not plugin_path.exists():
|
|
26
|
+
print(f"Error: Plugin not found at {plugin_path}", file=sys.stderr)
|
|
27
|
+
return 1
|
|
28
|
+
|
|
29
|
+
pymolrc_path = Path.home() / ".pymolrc"
|
|
30
|
+
|
|
31
|
+
# Check if already configured
|
|
32
|
+
if pymolrc_path.exists():
|
|
33
|
+
content = pymolrc_path.read_text()
|
|
34
|
+
if "claudemol" in content or "claude_socket_plugin" in content:
|
|
35
|
+
print("PyMOL already configured for claudemol.")
|
|
36
|
+
print(f"Plugin: {plugin_path}")
|
|
37
|
+
return 0
|
|
38
|
+
|
|
39
|
+
# Add to .pymolrc
|
|
40
|
+
run_command = f'\n# claudemol: Claude Code integration\nrun {plugin_path}\n'
|
|
41
|
+
|
|
42
|
+
if pymolrc_path.exists():
|
|
43
|
+
with open(pymolrc_path, "a") as f:
|
|
44
|
+
f.write(run_command)
|
|
45
|
+
print(f"Added claudemol plugin to existing {pymolrc_path}")
|
|
46
|
+
else:
|
|
47
|
+
pymolrc_path.write_text(run_command.lstrip())
|
|
48
|
+
print(f"Created {pymolrc_path} with claudemol plugin")
|
|
49
|
+
|
|
50
|
+
print(f"Plugin path: {plugin_path}")
|
|
51
|
+
print("\nSetup complete! The plugin will auto-load when you start PyMOL.")
|
|
52
|
+
|
|
53
|
+
# Check if PyMOL is installed
|
|
54
|
+
if not check_pymol_installed():
|
|
55
|
+
print("\nNote: PyMOL not found in PATH.")
|
|
56
|
+
print("Install PyMOL with one of:")
|
|
57
|
+
print(" - pip install pymol-open-source-whl")
|
|
58
|
+
print(" - brew install pymol (macOS)")
|
|
59
|
+
print(" - Download from https://pymol.org")
|
|
60
|
+
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_status():
|
|
65
|
+
"""Check PyMOL connection status."""
|
|
66
|
+
print("Checking PyMOL status...")
|
|
67
|
+
|
|
68
|
+
# Check if PyMOL is installed
|
|
69
|
+
pymol_cmd = find_pymol_command()
|
|
70
|
+
if pymol_cmd:
|
|
71
|
+
print(f"PyMOL found: {' '.join(pymol_cmd)}")
|
|
72
|
+
else:
|
|
73
|
+
print("PyMOL not found in PATH")
|
|
74
|
+
return 1
|
|
75
|
+
|
|
76
|
+
# Try to connect
|
|
77
|
+
conn = PyMOLConnection()
|
|
78
|
+
try:
|
|
79
|
+
conn.connect(timeout=2.0)
|
|
80
|
+
print("Socket connection: OK (port 9880)")
|
|
81
|
+
conn.disconnect()
|
|
82
|
+
return 0
|
|
83
|
+
except ConnectionError:
|
|
84
|
+
print("Socket connection: Not available")
|
|
85
|
+
print(" (PyMOL may not be running, or plugin not loaded)")
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_connection():
|
|
90
|
+
"""Test the PyMOL connection with a simple command."""
|
|
91
|
+
conn = PyMOLConnection()
|
|
92
|
+
try:
|
|
93
|
+
conn.connect(timeout=2.0)
|
|
94
|
+
result = conn.execute("print('claudemol connection test')")
|
|
95
|
+
print("Connection test: OK")
|
|
96
|
+
print(f"Response: {result}")
|
|
97
|
+
conn.disconnect()
|
|
98
|
+
return 0
|
|
99
|
+
except ConnectionError as e:
|
|
100
|
+
print(f"Connection failed: {e}", file=sys.stderr)
|
|
101
|
+
print("\nMake sure PyMOL is running with the socket plugin.")
|
|
102
|
+
print("Start PyMOL and run: claude_status")
|
|
103
|
+
return 1
|
|
104
|
+
except Exception as e:
|
|
105
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def show_info():
|
|
110
|
+
"""Show claudemol installation info."""
|
|
111
|
+
plugin_path = get_plugin_path()
|
|
112
|
+
pymolrc_path = Path.home() / ".pymolrc"
|
|
113
|
+
|
|
114
|
+
print("claudemol installation info:")
|
|
115
|
+
print(f" Plugin: {plugin_path}")
|
|
116
|
+
print(f" Plugin exists: {plugin_path.exists()}")
|
|
117
|
+
print(f" .pymolrc: {pymolrc_path}")
|
|
118
|
+
print(f" .pymolrc exists: {pymolrc_path.exists()}")
|
|
119
|
+
|
|
120
|
+
if pymolrc_path.exists():
|
|
121
|
+
content = pymolrc_path.read_text()
|
|
122
|
+
configured = "claudemol" in content or "claude_socket_plugin" in content
|
|
123
|
+
print(f" Configured in .pymolrc: {configured}")
|
|
124
|
+
|
|
125
|
+
pymol_cmd = find_pymol_command()
|
|
126
|
+
print(f" PyMOL command: {' '.join(pymol_cmd) if pymol_cmd else 'not found'}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def main():
|
|
130
|
+
parser = argparse.ArgumentParser(
|
|
131
|
+
description="claudemol: PyMOL integration for Claude Code",
|
|
132
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
133
|
+
epilog="""
|
|
134
|
+
Commands:
|
|
135
|
+
setup Configure PyMOL to auto-load the socket plugin
|
|
136
|
+
status Check if PyMOL is running and connected
|
|
137
|
+
test Test the connection with a simple command
|
|
138
|
+
info Show installation info
|
|
139
|
+
|
|
140
|
+
For Claude Code skills, install the claudemol-skills plugin:
|
|
141
|
+
/plugin marketplace add ANaka/ai-mol?path=claude-plugin
|
|
142
|
+
/plugin install claudemol-skills
|
|
143
|
+
""",
|
|
144
|
+
)
|
|
145
|
+
parser.add_argument(
|
|
146
|
+
"command",
|
|
147
|
+
nargs="?",
|
|
148
|
+
choices=["setup", "status", "test", "info"],
|
|
149
|
+
default="info",
|
|
150
|
+
help="Command to run",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
args = parser.parse_args()
|
|
154
|
+
|
|
155
|
+
if args.command == "setup":
|
|
156
|
+
return setup_pymol()
|
|
157
|
+
elif args.command == "status":
|
|
158
|
+
return check_status()
|
|
159
|
+
elif args.command == "test":
|
|
160
|
+
return test_connection()
|
|
161
|
+
elif args.command == "info":
|
|
162
|
+
show_info()
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
if __name__ == "__main__":
|
|
167
|
+
sys.exit(main())
|
claudemol/connection.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyMOL Connection Module
|
|
3
|
+
|
|
4
|
+
Provides functions for Claude Code to communicate with PyMOL via socket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import socket
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
DEFAULT_HOST = "localhost"
|
|
16
|
+
DEFAULT_PORT = 9880
|
|
17
|
+
CONNECT_TIMEOUT = 5.0
|
|
18
|
+
RECV_TIMEOUT = 30.0
|
|
19
|
+
|
|
20
|
+
# Common PyMOL installation paths
|
|
21
|
+
PYMOL_PATHS = [
|
|
22
|
+
# uv environment (created by /pymol-setup)
|
|
23
|
+
os.path.expanduser("~/.pymol-env/bin/python"),
|
|
24
|
+
# macOS app locations
|
|
25
|
+
"/Applications/PyMOL.app/Contents/MacOS/PyMOL",
|
|
26
|
+
os.path.expanduser("~/Applications/PyMOL.app/Contents/MacOS/PyMOL"),
|
|
27
|
+
# Linux system locations
|
|
28
|
+
"/usr/bin/pymol",
|
|
29
|
+
"/usr/local/bin/pymol",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class PyMOLConnection:
|
|
34
|
+
def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT):
|
|
35
|
+
self.host = host
|
|
36
|
+
self.port = port
|
|
37
|
+
self.socket = None
|
|
38
|
+
|
|
39
|
+
def connect(self, timeout=CONNECT_TIMEOUT):
|
|
40
|
+
"""Connect to PyMOL socket server."""
|
|
41
|
+
if self.socket:
|
|
42
|
+
return True
|
|
43
|
+
try:
|
|
44
|
+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
45
|
+
self.socket.settimeout(timeout)
|
|
46
|
+
self.socket.connect((self.host, self.port))
|
|
47
|
+
self.socket.settimeout(RECV_TIMEOUT)
|
|
48
|
+
return True
|
|
49
|
+
except Exception as e:
|
|
50
|
+
self.socket = None
|
|
51
|
+
raise ConnectionError(
|
|
52
|
+
f"Cannot connect to PyMOL on {self.host}:{self.port}: {e}"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def disconnect(self):
|
|
56
|
+
"""Disconnect from PyMOL."""
|
|
57
|
+
if self.socket:
|
|
58
|
+
try:
|
|
59
|
+
self.socket.close()
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
self.socket = None
|
|
63
|
+
|
|
64
|
+
def is_connected(self):
|
|
65
|
+
"""Check if connected to PyMOL."""
|
|
66
|
+
if not self.socket:
|
|
67
|
+
return False
|
|
68
|
+
try:
|
|
69
|
+
self.socket.setblocking(False)
|
|
70
|
+
try:
|
|
71
|
+
data = self.socket.recv(1, socket.MSG_PEEK)
|
|
72
|
+
if data == b"":
|
|
73
|
+
self.disconnect()
|
|
74
|
+
return False
|
|
75
|
+
except BlockingIOError:
|
|
76
|
+
pass
|
|
77
|
+
finally:
|
|
78
|
+
self.socket.setblocking(True)
|
|
79
|
+
self.socket.settimeout(RECV_TIMEOUT)
|
|
80
|
+
return True
|
|
81
|
+
except OSError:
|
|
82
|
+
self.disconnect()
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
def send_command(self, code):
|
|
86
|
+
"""Send Python code to PyMOL and return result."""
|
|
87
|
+
if not self.socket:
|
|
88
|
+
raise ConnectionError("Not connected to PyMOL")
|
|
89
|
+
try:
|
|
90
|
+
message = json.dumps({"type": "execute", "code": code})
|
|
91
|
+
self.socket.sendall(message.encode("utf-8"))
|
|
92
|
+
response = b""
|
|
93
|
+
while True:
|
|
94
|
+
chunk = self.socket.recv(4096)
|
|
95
|
+
if not chunk:
|
|
96
|
+
raise ConnectionError("Connection closed by PyMOL")
|
|
97
|
+
response += chunk
|
|
98
|
+
try:
|
|
99
|
+
result = json.loads(response.decode("utf-8"))
|
|
100
|
+
return result
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
continue
|
|
103
|
+
except socket.timeout:
|
|
104
|
+
raise TimeoutError("PyMOL command timed out")
|
|
105
|
+
except Exception as e:
|
|
106
|
+
self.disconnect()
|
|
107
|
+
raise ConnectionError(f"Communication error: {e}")
|
|
108
|
+
|
|
109
|
+
def execute(self, code):
|
|
110
|
+
"""Execute code, reconnecting if necessary. Returns output string or raises."""
|
|
111
|
+
for attempt in range(3):
|
|
112
|
+
try:
|
|
113
|
+
if not self.is_connected():
|
|
114
|
+
self.connect()
|
|
115
|
+
result = self.send_command(code)
|
|
116
|
+
if result.get("status") == "success":
|
|
117
|
+
return result.get("output", "")
|
|
118
|
+
else:
|
|
119
|
+
raise RuntimeError(result.get("error", "Unknown error"))
|
|
120
|
+
except ConnectionError:
|
|
121
|
+
if attempt < 2:
|
|
122
|
+
time.sleep(0.5)
|
|
123
|
+
continue
|
|
124
|
+
raise
|
|
125
|
+
raise ConnectionError("Failed to connect after 3 attempts")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def find_pymol_command():
|
|
129
|
+
"""
|
|
130
|
+
Find how to launch PyMOL.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
List of command arguments (e.g., ["pymol"] or ["python", "-m", "pymol"])
|
|
134
|
+
or None if PyMOL is not found.
|
|
135
|
+
"""
|
|
136
|
+
# Check if pymol is in PATH
|
|
137
|
+
pymol_path = shutil.which("pymol")
|
|
138
|
+
if pymol_path:
|
|
139
|
+
return [pymol_path]
|
|
140
|
+
|
|
141
|
+
# Check uv environment first (most common for this project)
|
|
142
|
+
uv_python = os.path.expanduser("~/.pymol-env/bin/python")
|
|
143
|
+
if os.path.isfile(uv_python) and os.access(uv_python, os.X_OK):
|
|
144
|
+
# Verify pymol is installed in this environment
|
|
145
|
+
try:
|
|
146
|
+
result = subprocess.run(
|
|
147
|
+
[uv_python, "-c", "import pymol"],
|
|
148
|
+
capture_output=True,
|
|
149
|
+
timeout=5,
|
|
150
|
+
)
|
|
151
|
+
if result.returncode == 0:
|
|
152
|
+
return [uv_python, "-m", "pymol"]
|
|
153
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
# Check other common paths
|
|
157
|
+
for path in PYMOL_PATHS:
|
|
158
|
+
if path.endswith("/python"):
|
|
159
|
+
continue # Already checked uv environment above
|
|
160
|
+
if os.path.isfile(path) and os.access(path, os.X_OK):
|
|
161
|
+
return [path]
|
|
162
|
+
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def find_pymol_executable():
|
|
167
|
+
"""Find PyMOL executable path (legacy compatibility)."""
|
|
168
|
+
cmd = find_pymol_command()
|
|
169
|
+
return cmd[0] if cmd else None
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def check_pymol_installed():
|
|
173
|
+
"""Check if pymol command is available."""
|
|
174
|
+
return find_pymol_command() is not None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_plugin_path():
|
|
178
|
+
"""Get the path to the socket plugin file."""
|
|
179
|
+
return Path(__file__).parent / "plugin.py"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def launch_pymol(file_path=None, wait_for_socket=True, timeout=10.0):
|
|
183
|
+
"""
|
|
184
|
+
Launch PyMOL with the Claude socket plugin.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
file_path: Optional file to open (e.g., .pdb, .cif)
|
|
188
|
+
wait_for_socket: Wait for socket to become available
|
|
189
|
+
timeout: How long to wait for socket
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
subprocess.Popen process handle
|
|
193
|
+
"""
|
|
194
|
+
pymol_cmd = find_pymol_command()
|
|
195
|
+
if not pymol_cmd:
|
|
196
|
+
raise RuntimeError(
|
|
197
|
+
"PyMOL not found. Please install PyMOL:\n"
|
|
198
|
+
" - Run: ai-mol setup\n"
|
|
199
|
+
" - Or: pip install pymol-open-source-whl\n"
|
|
200
|
+
" - Or: brew install pymol (macOS)"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
plugin_path = get_plugin_path()
|
|
204
|
+
if not plugin_path.exists():
|
|
205
|
+
raise RuntimeError(f"Plugin not found: {plugin_path}")
|
|
206
|
+
|
|
207
|
+
# Build command: base pymol command + optional file + plugin
|
|
208
|
+
cmd_args = list(pymol_cmd)
|
|
209
|
+
if file_path:
|
|
210
|
+
cmd_args.append(str(file_path))
|
|
211
|
+
cmd_args.extend(["-d", f"run {plugin_path}"])
|
|
212
|
+
|
|
213
|
+
process = subprocess.Popen(cmd_args)
|
|
214
|
+
|
|
215
|
+
if wait_for_socket:
|
|
216
|
+
start = time.time()
|
|
217
|
+
while time.time() - start < timeout:
|
|
218
|
+
try:
|
|
219
|
+
conn = PyMOLConnection()
|
|
220
|
+
conn.connect(timeout=1.0)
|
|
221
|
+
conn.disconnect()
|
|
222
|
+
return process
|
|
223
|
+
except ConnectionError:
|
|
224
|
+
time.sleep(0.5)
|
|
225
|
+
raise TimeoutError(f"PyMOL socket not available after {timeout}s")
|
|
226
|
+
|
|
227
|
+
return process
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def connect_or_launch(file_path=None):
|
|
231
|
+
"""
|
|
232
|
+
Connect to existing PyMOL or launch new instance.
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
(PyMOLConnection, process_or_None)
|
|
236
|
+
"""
|
|
237
|
+
conn = PyMOLConnection()
|
|
238
|
+
|
|
239
|
+
# Try connecting to existing instance
|
|
240
|
+
try:
|
|
241
|
+
conn.connect(timeout=1.0)
|
|
242
|
+
return conn, None
|
|
243
|
+
except ConnectionError:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
# Launch new instance
|
|
247
|
+
process = launch_pymol(file_path=file_path)
|
|
248
|
+
conn.connect()
|
|
249
|
+
return conn, process
|
claudemol/plugin.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Claude Socket Plugin for PyMOL
|
|
3
|
+
|
|
4
|
+
Headless socket listener that receives Python commands from Claude Code.
|
|
5
|
+
Auto-starts on load, no UI required.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
run /path/to/plugin.py # Start listening
|
|
9
|
+
claude_status # Check connection status
|
|
10
|
+
claude_stop # Stop listener
|
|
11
|
+
claude_start # Restart listener
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import socket
|
|
15
|
+
import json
|
|
16
|
+
import threading
|
|
17
|
+
import traceback
|
|
18
|
+
import io
|
|
19
|
+
from contextlib import redirect_stdout
|
|
20
|
+
|
|
21
|
+
from pymol import cmd
|
|
22
|
+
|
|
23
|
+
# Global state
|
|
24
|
+
_server = None
|
|
25
|
+
_port = 9880
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SocketServer:
|
|
29
|
+
def __init__(self, host='localhost', port=9880):
|
|
30
|
+
self.host = host
|
|
31
|
+
self.port = port
|
|
32
|
+
self.socket = None
|
|
33
|
+
self.client = None
|
|
34
|
+
self.running = False
|
|
35
|
+
self.thread = None
|
|
36
|
+
|
|
37
|
+
def start(self):
|
|
38
|
+
if self.running:
|
|
39
|
+
return False
|
|
40
|
+
self.running = True
|
|
41
|
+
self.thread = threading.Thread(target=self._run_server, daemon=True)
|
|
42
|
+
self.thread.start()
|
|
43
|
+
return True
|
|
44
|
+
|
|
45
|
+
def _run_server(self):
|
|
46
|
+
try:
|
|
47
|
+
self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
48
|
+
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
49
|
+
self.socket.bind((self.host, self.port))
|
|
50
|
+
self.socket.listen(5)
|
|
51
|
+
self.socket.settimeout(1.0)
|
|
52
|
+
|
|
53
|
+
print(f"Claude socket listener active on port {self.port}")
|
|
54
|
+
|
|
55
|
+
while self.running:
|
|
56
|
+
try:
|
|
57
|
+
new_client, address = self.socket.accept()
|
|
58
|
+
if self.client:
|
|
59
|
+
try:
|
|
60
|
+
self.client.close()
|
|
61
|
+
except:
|
|
62
|
+
pass
|
|
63
|
+
self.client = new_client
|
|
64
|
+
self.client.settimeout(1.0)
|
|
65
|
+
self._handle_client(address)
|
|
66
|
+
except socket.timeout:
|
|
67
|
+
continue
|
|
68
|
+
except Exception as e:
|
|
69
|
+
if self.running:
|
|
70
|
+
print(f"Connection error: {e}")
|
|
71
|
+
except Exception as e:
|
|
72
|
+
print(f"Socket server error: {e}")
|
|
73
|
+
traceback.print_exc()
|
|
74
|
+
finally:
|
|
75
|
+
self._cleanup()
|
|
76
|
+
|
|
77
|
+
def _handle_client(self, address):
|
|
78
|
+
buffer = b''
|
|
79
|
+
while self.running and self.client:
|
|
80
|
+
try:
|
|
81
|
+
data = self.client.recv(4096)
|
|
82
|
+
if not data:
|
|
83
|
+
break
|
|
84
|
+
buffer += data
|
|
85
|
+
try:
|
|
86
|
+
command = json.loads(buffer.decode('utf-8'))
|
|
87
|
+
buffer = b''
|
|
88
|
+
result = self._execute_command(command)
|
|
89
|
+
response = json.dumps(result)
|
|
90
|
+
self.client.sendall(response.encode('utf-8'))
|
|
91
|
+
except json.JSONDecodeError:
|
|
92
|
+
continue
|
|
93
|
+
except socket.timeout:
|
|
94
|
+
continue
|
|
95
|
+
except Exception as e:
|
|
96
|
+
if self.running:
|
|
97
|
+
print(f"Client error: {e}")
|
|
98
|
+
break
|
|
99
|
+
if self.client:
|
|
100
|
+
try:
|
|
101
|
+
self.client.close()
|
|
102
|
+
except:
|
|
103
|
+
pass
|
|
104
|
+
self.client = None
|
|
105
|
+
|
|
106
|
+
def _execute_command(self, command):
|
|
107
|
+
code = command.get("code", "")
|
|
108
|
+
if not code:
|
|
109
|
+
return {"status": "error", "error": "No code provided"}
|
|
110
|
+
try:
|
|
111
|
+
exec_globals = {"cmd": cmd, "__builtins__": __builtins__}
|
|
112
|
+
output_buffer = io.StringIO()
|
|
113
|
+
with redirect_stdout(output_buffer):
|
|
114
|
+
exec(code, exec_globals)
|
|
115
|
+
output = output_buffer.getvalue()
|
|
116
|
+
if '_result' in exec_globals:
|
|
117
|
+
output = str(exec_globals['_result'])
|
|
118
|
+
return {"status": "success", "output": output or "OK"}
|
|
119
|
+
except Exception as e:
|
|
120
|
+
return {"status": "error", "error": str(e)}
|
|
121
|
+
|
|
122
|
+
def _cleanup(self):
|
|
123
|
+
if self.client:
|
|
124
|
+
try:
|
|
125
|
+
self.client.close()
|
|
126
|
+
except:
|
|
127
|
+
pass
|
|
128
|
+
if self.socket:
|
|
129
|
+
try:
|
|
130
|
+
self.socket.close()
|
|
131
|
+
except:
|
|
132
|
+
pass
|
|
133
|
+
self.socket = None
|
|
134
|
+
self.client = None
|
|
135
|
+
self.running = False
|
|
136
|
+
|
|
137
|
+
def stop(self):
|
|
138
|
+
self.running = False
|
|
139
|
+
if self.thread:
|
|
140
|
+
self.thread.join(2.0)
|
|
141
|
+
self._cleanup()
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def is_running(self):
|
|
145
|
+
return self.running and self.thread and self.thread.is_alive()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def claude_status():
|
|
149
|
+
"""Print Claude socket listener status."""
|
|
150
|
+
global _server
|
|
151
|
+
if _server and _server.is_running:
|
|
152
|
+
connected = "connected" if _server.client else "waiting"
|
|
153
|
+
print(f"Claude socket listener: running on port {_port} ({connected})")
|
|
154
|
+
else:
|
|
155
|
+
print("Claude socket listener: not running")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def claude_stop():
|
|
159
|
+
"""Stop the Claude socket listener."""
|
|
160
|
+
global _server
|
|
161
|
+
if _server:
|
|
162
|
+
_server.stop()
|
|
163
|
+
_server = None
|
|
164
|
+
print("Claude socket listener stopped")
|
|
165
|
+
else:
|
|
166
|
+
print("Claude socket listener was not running")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def claude_start(port=9880):
|
|
170
|
+
"""Start the Claude socket listener."""
|
|
171
|
+
global _server, _port
|
|
172
|
+
if _server and _server.is_running:
|
|
173
|
+
print(f"Claude socket listener already running on port {_port}")
|
|
174
|
+
return
|
|
175
|
+
_port = port
|
|
176
|
+
_server = SocketServer(port=port)
|
|
177
|
+
_server.start()
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Register commands with PyMOL
|
|
181
|
+
cmd.extend("claude_status", claude_status)
|
|
182
|
+
cmd.extend("claude_stop", claude_stop)
|
|
183
|
+
cmd.extend("claude_start", claude_start)
|
|
184
|
+
|
|
185
|
+
# Auto-start on load
|
|
186
|
+
claude_start()
|
claudemol/session.py
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyMOL Session Manager
|
|
3
|
+
|
|
4
|
+
Provides reliable session lifecycle management:
|
|
5
|
+
- Launch PyMOL with plugin
|
|
6
|
+
- Health checks
|
|
7
|
+
- Graceful and forced termination
|
|
8
|
+
- Crash detection and recovery
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from claudemol.connection import (
|
|
18
|
+
DEFAULT_HOST,
|
|
19
|
+
DEFAULT_PORT,
|
|
20
|
+
PyMOLConnection,
|
|
21
|
+
find_pymol_command,
|
|
22
|
+
get_plugin_path,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PyMOLSession:
|
|
27
|
+
"""
|
|
28
|
+
Manages a PyMOL session with health monitoring and recovery.
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
session = PyMOLSession()
|
|
32
|
+
session.start()
|
|
33
|
+
|
|
34
|
+
# Use the connection
|
|
35
|
+
result = session.execute("cmd.fetch('1ubq')")
|
|
36
|
+
|
|
37
|
+
# Check health
|
|
38
|
+
if not session.is_healthy():
|
|
39
|
+
session.recover()
|
|
40
|
+
|
|
41
|
+
# Clean up
|
|
42
|
+
session.stop()
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, host=DEFAULT_HOST, port=DEFAULT_PORT):
|
|
46
|
+
self.host = host
|
|
47
|
+
self.port = port
|
|
48
|
+
self.process = None
|
|
49
|
+
self.connection = None
|
|
50
|
+
self._we_launched = False # Track if we started PyMOL
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def is_running(self):
|
|
54
|
+
"""Check if we have a PyMOL process that's still alive."""
|
|
55
|
+
if self.process is None:
|
|
56
|
+
return False
|
|
57
|
+
return self.process.poll() is None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_connected(self):
|
|
61
|
+
"""Check if we have a socket connection."""
|
|
62
|
+
return self.connection is not None and self.connection.is_connected()
|
|
63
|
+
|
|
64
|
+
def is_healthy(self):
|
|
65
|
+
"""
|
|
66
|
+
Check if PyMOL is responsive by executing a trivial command.
|
|
67
|
+
|
|
68
|
+
Returns True if we can communicate with PyMOL.
|
|
69
|
+
"""
|
|
70
|
+
if not self.is_connected:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
result = self.connection.execute("print('ping')")
|
|
74
|
+
return "ping" in result
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
def start(self, timeout=15.0):
|
|
79
|
+
"""
|
|
80
|
+
Start PyMOL or connect to existing instance.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
timeout: How long to wait for PyMOL to be ready
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
True if connected successfully
|
|
87
|
+
"""
|
|
88
|
+
self.connection = PyMOLConnection(self.host, self.port)
|
|
89
|
+
|
|
90
|
+
# Try connecting to existing instance first
|
|
91
|
+
try:
|
|
92
|
+
self.connection.connect(timeout=2.0)
|
|
93
|
+
self._we_launched = False
|
|
94
|
+
return True
|
|
95
|
+
except ConnectionError:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
# Launch new instance
|
|
99
|
+
pymol_cmd = find_pymol_command()
|
|
100
|
+
if not pymol_cmd:
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
"PyMOL not found. Run: claudemol setup"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Check if plugin is configured in pymolrc (don't double-load)
|
|
106
|
+
pymolrc_path = Path.home() / ".pymolrc"
|
|
107
|
+
plugin_in_pymolrc = False
|
|
108
|
+
if pymolrc_path.exists():
|
|
109
|
+
content = pymolrc_path.read_text()
|
|
110
|
+
if "claude_socket_plugin" in content or "claudemol" in content:
|
|
111
|
+
plugin_in_pymolrc = True
|
|
112
|
+
|
|
113
|
+
# Build command - only add plugin if not in pymolrc
|
|
114
|
+
cmd_args = list(pymol_cmd)
|
|
115
|
+
if not plugin_in_pymolrc:
|
|
116
|
+
plugin_path = get_plugin_path()
|
|
117
|
+
if not plugin_path.exists():
|
|
118
|
+
raise RuntimeError(f"Plugin not found: {plugin_path}")
|
|
119
|
+
cmd_args += ["-d", f"run {plugin_path}"]
|
|
120
|
+
|
|
121
|
+
# Launch PyMOL
|
|
122
|
+
self.process = subprocess.Popen(
|
|
123
|
+
cmd_args,
|
|
124
|
+
stdout=subprocess.PIPE,
|
|
125
|
+
stderr=subprocess.PIPE,
|
|
126
|
+
)
|
|
127
|
+
self._we_launched = True
|
|
128
|
+
|
|
129
|
+
# Wait for socket to become available
|
|
130
|
+
start = time.time()
|
|
131
|
+
while time.time() - start < timeout:
|
|
132
|
+
try:
|
|
133
|
+
self.connection.connect(timeout=1.0)
|
|
134
|
+
return True
|
|
135
|
+
except ConnectionError:
|
|
136
|
+
if not self.is_running:
|
|
137
|
+
# Process died during startup
|
|
138
|
+
stdout, stderr = self.process.communicate(timeout=1)
|
|
139
|
+
raise RuntimeError(
|
|
140
|
+
f"PyMOL exited during startup.\n"
|
|
141
|
+
f"stdout: {stdout.decode()}\n"
|
|
142
|
+
f"stderr: {stderr.decode()}"
|
|
143
|
+
)
|
|
144
|
+
time.sleep(0.5)
|
|
145
|
+
|
|
146
|
+
# Timeout - kill the process
|
|
147
|
+
self._kill_process()
|
|
148
|
+
raise TimeoutError(f"PyMOL socket not available after {timeout}s")
|
|
149
|
+
|
|
150
|
+
def stop(self, graceful_timeout=5.0):
|
|
151
|
+
"""
|
|
152
|
+
Stop PyMOL session.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
graceful_timeout: Time to wait for graceful shutdown before force kill
|
|
156
|
+
"""
|
|
157
|
+
# Disconnect socket
|
|
158
|
+
if self.connection:
|
|
159
|
+
self.connection.disconnect()
|
|
160
|
+
self.connection = None
|
|
161
|
+
|
|
162
|
+
# Only kill process if we launched it
|
|
163
|
+
if self._we_launched and self.process:
|
|
164
|
+
self._kill_process(graceful_timeout)
|
|
165
|
+
|
|
166
|
+
def _kill_process(self, graceful_timeout=5.0):
|
|
167
|
+
"""Kill the PyMOL process."""
|
|
168
|
+
if not self.process:
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
# Try graceful termination first
|
|
172
|
+
try:
|
|
173
|
+
self.process.terminate()
|
|
174
|
+
self.process.wait(timeout=graceful_timeout)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
# Force kill
|
|
177
|
+
self.process.kill()
|
|
178
|
+
self.process.wait(timeout=2.0)
|
|
179
|
+
except Exception:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
self.process = None
|
|
183
|
+
self._we_launched = False
|
|
184
|
+
|
|
185
|
+
def recover(self, timeout=15.0):
|
|
186
|
+
"""
|
|
187
|
+
Recover from a crashed or unresponsive PyMOL.
|
|
188
|
+
|
|
189
|
+
This will:
|
|
190
|
+
1. Kill any stale process
|
|
191
|
+
2. Disconnect stale socket
|
|
192
|
+
3. Start fresh
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if recovery successful
|
|
196
|
+
"""
|
|
197
|
+
# Clean up
|
|
198
|
+
if self.connection:
|
|
199
|
+
self.connection.disconnect()
|
|
200
|
+
self.connection = None
|
|
201
|
+
|
|
202
|
+
if self._we_launched:
|
|
203
|
+
self._kill_process(graceful_timeout=2.0)
|
|
204
|
+
|
|
205
|
+
# Also try to kill any orphaned PyMOL processes on our port
|
|
206
|
+
self._kill_processes_on_port()
|
|
207
|
+
|
|
208
|
+
# Brief pause to let OS clean up
|
|
209
|
+
time.sleep(0.5)
|
|
210
|
+
|
|
211
|
+
# Start fresh
|
|
212
|
+
return self.start(timeout=timeout)
|
|
213
|
+
|
|
214
|
+
def _kill_processes_on_port(self):
|
|
215
|
+
"""Kill any processes listening on our port (Linux/macOS)."""
|
|
216
|
+
try:
|
|
217
|
+
# Find PIDs using our port
|
|
218
|
+
result = subprocess.run(
|
|
219
|
+
["lsof", "-ti", f":{self.port}"],
|
|
220
|
+
capture_output=True,
|
|
221
|
+
text=True,
|
|
222
|
+
timeout=5,
|
|
223
|
+
)
|
|
224
|
+
if result.stdout.strip():
|
|
225
|
+
pids = result.stdout.strip().split("\n")
|
|
226
|
+
for pid in pids:
|
|
227
|
+
try:
|
|
228
|
+
os.kill(int(pid), signal.SIGKILL)
|
|
229
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
230
|
+
pass
|
|
231
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
232
|
+
pass
|
|
233
|
+
|
|
234
|
+
def execute(self, code, auto_recover=True):
|
|
235
|
+
"""
|
|
236
|
+
Execute code in PyMOL with optional auto-recovery.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
code: Python code to execute in PyMOL
|
|
240
|
+
auto_recover: If True, attempt recovery on failure
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Output from PyMOL
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
if not self.is_connected:
|
|
247
|
+
if auto_recover:
|
|
248
|
+
self.recover()
|
|
249
|
+
else:
|
|
250
|
+
raise ConnectionError("Not connected to PyMOL")
|
|
251
|
+
|
|
252
|
+
return self.connection.execute(code)
|
|
253
|
+
|
|
254
|
+
except (ConnectionError, TimeoutError):
|
|
255
|
+
if auto_recover:
|
|
256
|
+
self.recover()
|
|
257
|
+
return self.connection.execute(code)
|
|
258
|
+
raise
|
|
259
|
+
|
|
260
|
+
def __enter__(self):
|
|
261
|
+
"""Context manager entry."""
|
|
262
|
+
self.start()
|
|
263
|
+
return self
|
|
264
|
+
|
|
265
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
266
|
+
"""Context manager exit."""
|
|
267
|
+
self.stop()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# Convenience: global session instance
|
|
271
|
+
_session = None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def get_session():
|
|
275
|
+
"""Get or create the global PyMOL session."""
|
|
276
|
+
global _session
|
|
277
|
+
if _session is None:
|
|
278
|
+
_session = PyMOLSession()
|
|
279
|
+
return _session
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def ensure_running():
|
|
283
|
+
"""Ensure PyMOL is running and connected."""
|
|
284
|
+
session = get_session()
|
|
285
|
+
if not session.is_healthy():
|
|
286
|
+
session.start()
|
|
287
|
+
return session
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def stop_pymol():
|
|
291
|
+
"""Stop the global PyMOL session."""
|
|
292
|
+
global _session
|
|
293
|
+
if _session:
|
|
294
|
+
_session.stop()
|
|
295
|
+
_session = None
|
claudemol/view.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Visual feedback helper for PyMOL.
|
|
3
|
+
|
|
4
|
+
Provides a simple way to execute commands and save/view the result.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from ai_mol.view import pymol_view
|
|
8
|
+
|
|
9
|
+
# Execute commands and save a snapshot
|
|
10
|
+
path = pymol_view("cmd.fetch('1ubq'); cmd.show('cartoon')", name="ubq_cartoon")
|
|
11
|
+
|
|
12
|
+
# Or with auto-naming
|
|
13
|
+
path = pymol_view("cmd.color('red', 'all')")
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import socket
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
DEFAULT_HOST = "localhost"
|
|
23
|
+
DEFAULT_PORT = 9880
|
|
24
|
+
SCRATCH_DIR = Path.home() / ".ai-mol" / "scratch"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ensure_scratch_dir():
|
|
28
|
+
"""Ensure the scratch directory exists."""
|
|
29
|
+
SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
return SCRATCH_DIR
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def send_command(code: str, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT, timeout: float = 120.0) -> dict:
|
|
34
|
+
"""Send a command to PyMOL and return the result."""
|
|
35
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
36
|
+
s.settimeout(timeout)
|
|
37
|
+
try:
|
|
38
|
+
s.connect((host, port))
|
|
39
|
+
msg = json.dumps({"type": "execute", "code": code})
|
|
40
|
+
s.sendall(msg.encode())
|
|
41
|
+
|
|
42
|
+
response = b""
|
|
43
|
+
while True:
|
|
44
|
+
chunk = s.recv(4096)
|
|
45
|
+
if not chunk:
|
|
46
|
+
break
|
|
47
|
+
response += chunk
|
|
48
|
+
try:
|
|
49
|
+
return json.loads(response.decode())
|
|
50
|
+
except json.JSONDecodeError:
|
|
51
|
+
continue
|
|
52
|
+
finally:
|
|
53
|
+
s.close()
|
|
54
|
+
return {"status": "error", "error": "No response received"}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def generate_filename(name: str | None = None, extension: str = "png") -> Path:
|
|
58
|
+
"""Generate a unique filename in the scratch directory."""
|
|
59
|
+
ensure_scratch_dir()
|
|
60
|
+
|
|
61
|
+
if name:
|
|
62
|
+
# Clean the name
|
|
63
|
+
clean_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name)
|
|
64
|
+
return SCRATCH_DIR / f"{clean_name}.{extension}"
|
|
65
|
+
else:
|
|
66
|
+
# Auto-generate with timestamp
|
|
67
|
+
timestamp = datetime.now().strftime("%H%M%S")
|
|
68
|
+
return SCRATCH_DIR / f"view_{timestamp}.{extension}"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pymol_view(
|
|
72
|
+
commands: str,
|
|
73
|
+
name: str | None = None,
|
|
74
|
+
width: int = 800,
|
|
75
|
+
height: int = 600,
|
|
76
|
+
ray: bool = False,
|
|
77
|
+
port: int = DEFAULT_PORT,
|
|
78
|
+
) -> str:
|
|
79
|
+
"""
|
|
80
|
+
Execute PyMOL commands and save a snapshot.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
commands: PyMOL Python commands to execute (e.g., "cmd.fetch('1ubq')")
|
|
84
|
+
name: Optional name for the output file (auto-generated if None)
|
|
85
|
+
width: Image width in pixels
|
|
86
|
+
height: Image height in pixels
|
|
87
|
+
ray: Whether to ray-trace (slower but prettier)
|
|
88
|
+
port: PyMOL socket port
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Path to the saved image file
|
|
92
|
+
"""
|
|
93
|
+
output_path = generate_filename(name)
|
|
94
|
+
|
|
95
|
+
# Build the full command sequence
|
|
96
|
+
# IMPORTANT: cmd.png() with width/height parameters causes a PyMOL bug where
|
|
97
|
+
# the view matrix Z-distance becomes corrupted after multiple cycles of
|
|
98
|
+
# reinitialize + fetch + png. The Z-distance grows exponentially from ~120 to
|
|
99
|
+
# ~50000+ after just 3-4 cycles, causing the view to zoom out into invisibility.
|
|
100
|
+
#
|
|
101
|
+
# The fix: ALWAYS use cmd.ray(width, height) before cmd.png(path) WITHOUT
|
|
102
|
+
# dimensions in the png call. The ray() command renders to an offscreen buffer
|
|
103
|
+
# without touching the viewport, preventing the corruption.
|
|
104
|
+
|
|
105
|
+
render_cmd = f"cmd.ray({width}, {height})"
|
|
106
|
+
png_cmd = f'cmd.png(r"{output_path}")'
|
|
107
|
+
|
|
108
|
+
full_code = f"""
|
|
109
|
+
{commands}
|
|
110
|
+
|
|
111
|
+
# Render and save the image
|
|
112
|
+
{render_cmd}
|
|
113
|
+
{png_cmd}
|
|
114
|
+
print(f"Saved: {output_path}")
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
result = send_command(full_code, port=port, timeout=120.0)
|
|
118
|
+
|
|
119
|
+
if result.get("status") == "success":
|
|
120
|
+
# Wait briefly for file to be written
|
|
121
|
+
time.sleep(0.2)
|
|
122
|
+
if output_path.exists():
|
|
123
|
+
return str(output_path)
|
|
124
|
+
else:
|
|
125
|
+
raise RuntimeError(f"Image was not saved to {output_path}")
|
|
126
|
+
else:
|
|
127
|
+
raise RuntimeError(f"PyMOL error: {result.get('error', 'Unknown error')}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def quick_view(port: int = DEFAULT_PORT) -> str:
|
|
131
|
+
"""
|
|
132
|
+
Take a quick snapshot of the current PyMOL view.
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Path to the saved image
|
|
136
|
+
"""
|
|
137
|
+
return pymol_view("pass", name=None, port=port)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: claudemol
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PyMOL integration for Claude Code - control molecular visualization via natural language
|
|
5
|
+
Project-URL: Homepage, https://github.com/ANaka/ai-mol
|
|
6
|
+
Project-URL: Repository, https://github.com/ANaka/ai-mol
|
|
7
|
+
Author: Alex Naka
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,claude,molecular-visualization,pymol,structural-biology
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# ai-mol: Control PyMOL with Claude Code
|
|
24
|
+
|
|
25
|
+
Control PyMOL through natural language using Claude Code. This integration enables conversational structural biology, molecular visualization, and analysis.
|
|
26
|
+
|
|
27
|
+
https://github.com/user-attachments/assets/687f43dc-d45e-477e-ac2b-7438e175cb36
|
|
28
|
+
|
|
29
|
+
## Features
|
|
30
|
+
|
|
31
|
+
- **Natural language control**: Tell Claude what you want to visualize and it executes PyMOL commands
|
|
32
|
+
- **Direct socket communication**: Claude Code talks directly to PyMOL (no intermediary server)
|
|
33
|
+
- **Full PyMOL access**: Manipulate representations, colors, views, perform measurements, alignments, and more
|
|
34
|
+
- **Skill-based workflows**: Built-in skills for common tasks like binding site visualization and publication figures
|
|
35
|
+
|
|
36
|
+
## Architecture
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
Claude Code → TCP Socket (port 9880) → PyMOL Plugin → cmd.* execution
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
### Prerequisites
|
|
45
|
+
|
|
46
|
+
- PyMOL installed on your system
|
|
47
|
+
- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI installed
|
|
48
|
+
- Python 3.10+
|
|
49
|
+
|
|
50
|
+
### Installation
|
|
51
|
+
|
|
52
|
+
1. **Clone the repository:**
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
git clone https://github.com/ANaka/ai-mol
|
|
56
|
+
cd ai-mol
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
2. **Set up the PyMOL plugin:**
|
|
60
|
+
|
|
61
|
+
Add this line to your `~/.pymolrc` (create it if it doesn't exist):
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
run /path/to/ai-mol/claude_socket_plugin.py
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Replace `/path/to/ai-mol` with the actual path where you cloned the repository.
|
|
68
|
+
|
|
69
|
+
3. **Start using it:**
|
|
70
|
+
|
|
71
|
+
Open Claude Code in the `ai-mol` directory and say:
|
|
72
|
+
|
|
73
|
+
> "Open PyMOL and load structure 1UBQ"
|
|
74
|
+
|
|
75
|
+
Claude will launch PyMOL (with the socket listener active) and load the structure.
|
|
76
|
+
|
|
77
|
+
## Usage
|
|
78
|
+
|
|
79
|
+
### Starting a Session
|
|
80
|
+
|
|
81
|
+
Simply ask Claude to open PyMOL or load a structure:
|
|
82
|
+
|
|
83
|
+
- "Open PyMOL"
|
|
84
|
+
- "Load PDB 4HHB and show as cartoon"
|
|
85
|
+
- "Fetch 1UBQ from the PDB"
|
|
86
|
+
|
|
87
|
+
Claude will launch PyMOL if it's not already running.
|
|
88
|
+
|
|
89
|
+
### Example Commands
|
|
90
|
+
|
|
91
|
+
- "Color the protein by secondary structure"
|
|
92
|
+
- "Show the binding site residues within 5Å of the ligand as sticks"
|
|
93
|
+
- "Align these two structures and calculate RMSD"
|
|
94
|
+
- "Create a publication-quality figure with ray tracing"
|
|
95
|
+
- "Make a 360° rotation movie"
|
|
96
|
+
|
|
97
|
+
### PyMOL Console Commands
|
|
98
|
+
|
|
99
|
+
Check or control the socket listener from PyMOL's command line:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
claude_status # Check if listener is running
|
|
103
|
+
claude_stop # Stop the listener
|
|
104
|
+
claude_start # Start the listener
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Available Skills
|
|
108
|
+
|
|
109
|
+
Claude Code has built-in skills for common workflows:
|
|
110
|
+
|
|
111
|
+
- **pymol-fundamentals** - Basic visualization, selections, coloring
|
|
112
|
+
- **protein-structure-basics** - Secondary structure, B-factor, representations
|
|
113
|
+
- **binding-site-visualization** - Protein-ligand interactions
|
|
114
|
+
- **structure-alignment-analysis** - Comparing and aligning structures
|
|
115
|
+
- **antibody-visualization** - CDR loops, epitopes, Fab structures
|
|
116
|
+
- **publication-figures** - High-quality figure export
|
|
117
|
+
- **movie-creation** - Animations and rotations
|
|
118
|
+
|
|
119
|
+
## Troubleshooting
|
|
120
|
+
|
|
121
|
+
### Connection Issues
|
|
122
|
+
|
|
123
|
+
- **"Could not connect to PyMOL"**: Make sure PyMOL is running and the plugin is loaded
|
|
124
|
+
- **Check listener status**: Run `claude_status` in PyMOL's command line
|
|
125
|
+
- **Restart listener**: Run `claude_stop` then `claude_start` in PyMOL
|
|
126
|
+
|
|
127
|
+
### Plugin Not Loading
|
|
128
|
+
|
|
129
|
+
- Verify the path in your `~/.pymolrc` is correct
|
|
130
|
+
- Check PyMOL's output for any error messages on startup
|
|
131
|
+
- Try running `run /path/to/claude_socket_plugin.py` manually in PyMOL
|
|
132
|
+
|
|
133
|
+
### First-Time Setup Help
|
|
134
|
+
|
|
135
|
+
Run the `/pymol-setup` skill in Claude Code for guided setup assistance.
|
|
136
|
+
|
|
137
|
+
## Configuration
|
|
138
|
+
|
|
139
|
+
The default socket port is **9880**. Both the plugin and Claude Code connection module use this port.
|
|
140
|
+
|
|
141
|
+
Key files:
|
|
142
|
+
- `claude_socket_plugin.py` - PyMOL plugin (headless, auto-loads via pymolrc)
|
|
143
|
+
- `pymol_connection.py` - Python module for socket communication
|
|
144
|
+
- `.claude/skills/` - Claude Code skills for PyMOL workflows
|
|
145
|
+
|
|
146
|
+
## Limitations
|
|
147
|
+
|
|
148
|
+
- PyMOL and Claude Code must run on the same machine (localhost connection)
|
|
149
|
+
- One active connection at a time
|
|
150
|
+
- Some complex multi-step operations may need guidance
|
|
151
|
+
|
|
152
|
+
## Contributing
|
|
153
|
+
|
|
154
|
+
Contributions welcome! This project aims to build comprehensive skills for Claude-PyMOL interaction. If you discover useful patterns or workflows, consider adding them as skills.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
MIT License - see LICENSE file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
claudemol/__init__.py,sha256=RZDmQTql01kKApSDdMC3qy07Bz71CE1f6vyOJ_l7wHk,618
|
|
2
|
+
claudemol/cli.py,sha256=J6cKHGbkZX_gYAcvMVlN3NXkIEJmMSjAeEL4R7GO-ww,5008
|
|
3
|
+
claudemol/connection.py,sha256=PG-iqqJ8kYUrA9JQieigHNSzjdI-cab6c0SKUpZkxTg,7516
|
|
4
|
+
claudemol/plugin.py,sha256=xRqpd9Ncn2vDZnmZsKHcFrhISJng38FAZv_0Thy-axc,5513
|
|
5
|
+
claudemol/session.py,sha256=BtaIx9UalvnnlmQ1Zr9HFAlvt_0HzrAnT952HPpFG4w,8258
|
|
6
|
+
claudemol/view.py,sha256=-qe4xu_U_AuVtZpgj_XK3GqnUgq_8ClraHpJjFd58lU,4097
|
|
7
|
+
claudemol-0.1.0.dist-info/METADATA,sha256=kTIUkA5cu-dB3N4bC_qfLZidrEGXPEPAukAr6i7aU2o,5104
|
|
8
|
+
claudemol-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
9
|
+
claudemol-0.1.0.dist-info/entry_points.txt,sha256=zHSqaPcqaXWoGEavppdq_zTrMARmjjrTqLHa5KEBchw,49
|
|
10
|
+
claudemol-0.1.0.dist-info/licenses/LICENSE,sha256=sZWsSm99w9tlHsmCnA7_6KzEAqQaD7nSQlgYesvvWMQ,1075
|
|
11
|
+
claudemol-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vishnu Rajan Tejus
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|