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 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())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ claudemol = claudemol.cli:main
@@ -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.