diff --git a/software/control/_def.py b/software/control/_def.py index aa8f34c51..cd80908c5 100644 --- a/software/control/_def.py +++ b/software/control/_def.py @@ -1275,6 +1275,7 @@ def get_wellplate_settings(wellplate_format): ENABLE_MCP_SERVER_SUPPORT = True # Set to False to hide all MCP-related menu items CONTROL_SERVER_HOST = "127.0.0.1" CONTROL_SERVER_PORT = 5050 +ANTHROPIC_API_KEY = None # Set via GUI (Settings > Set Anthropic API Key...) # Slack Notifications - send real-time notifications during acquisition diff --git a/software/control/widgets_claude.py b/software/control/widgets_claude.py new file mode 100644 index 000000000..406433878 --- /dev/null +++ b/software/control/widgets_claude.py @@ -0,0 +1,176 @@ +"""Anthropic API key dialog for Claude Code integration. + +Provides a dialog for entering the Anthropic API key used when launching +Claude Code from the GUI. The key is cached locally in cache/claude_api_key.yaml. +""" + +import os + +import yaml + +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QDialog, + QVBoxLayout, + QHBoxLayout, + QLabel, + QPlainTextEdit, + QPushButton, +) + +import control._def +import squid.logging + +log = squid.logging.get_logger(__name__) + +CACHE_FILE = "cache/claude_api_key.yaml" + +_MASK_CHAR = "\u2022" # bullet character for masking + + +def load_claude_api_key_from_cache(): + """Load Anthropic API key from cache file into runtime config. + + This should be called during application startup to restore + the API key from the cache file. + """ + if not os.path.exists(CACHE_FILE): + return + try: + with open(CACHE_FILE, "r") as f: + data = yaml.safe_load(f) + if data is None: + return + if not isinstance(data, dict): + log.error("Anthropic API key cache file has unexpected format (expected YAML dict)") + return + key = data.get("api_key") + if key: + if not isinstance(key, str): + log.error("Anthropic API key cache has invalid type " f"(expected str, got {type(key).__name__})") + return + control._def.ANTHROPIC_API_KEY = key + log.info("Loaded Anthropic API key from cache") + except (yaml.YAMLError, OSError) as e: + log.error(f"Failed to load Anthropic API key from cache: {e}") + + +class ClaudeApiKeyDialog(QDialog): + """Dialog for entering the Anthropic API key used by Claude Code.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Set Anthropic API Key") + self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) + self.setModal(True) + self.setMinimumWidth(500) + + self._stored_key = "" + self._is_visible = False + + self._setup_ui() + self._load_key() + self._connect_signals() + + def _setup_ui(self): + layout = QVBoxLayout(self) + + # API key input — multi-line for long keys + layout.addWidget(QLabel("API Key:")) + self.textedit_api_key = QPlainTextEdit() + self.textedit_api_key.setPlaceholderText("sk-ant-...") + self.textedit_api_key.setMaximumHeight(60) + self.textedit_api_key.setTabChangesFocus(True) + layout.addWidget(self.textedit_api_key) + + # Show/hide toggle + toggle_layout = QHBoxLayout() + self.btn_show = QPushButton("Show") + self.btn_show.setCheckable(True) + self.btn_show.setMaximumWidth(60) + toggle_layout.addWidget(self.btn_show) + toggle_layout.addStretch() + layout.addLayout(toggle_layout) + + # Help text + help_label = QLabel( + "Get your API key from " + 'console.anthropic.com.
' + "The key is stored locally and passed to Claude Code on launch.
" + ) + help_label.setWordWrap(True) + help_label.setOpenExternalLinks(True) + help_label.setStyleSheet("color: gray;") + layout.addWidget(help_label) + + # Status label + self.label_status = QLabel("") + self.label_status.setStyleSheet("color: gray;") + layout.addWidget(self.label_status) + + # Buttons + button_layout = QHBoxLayout() + self.btn_clear = QPushButton("Clear") + self.btn_save = QPushButton("Save") + self.btn_close = QPushButton("Close") + button_layout.addWidget(self.btn_clear) + button_layout.addStretch() + button_layout.addWidget(self.btn_save) + button_layout.addWidget(self.btn_close) + layout.addLayout(button_layout) + + def _connect_signals(self): + self.btn_show.toggled.connect(self._toggle_visibility) + self.btn_clear.clicked.connect(self._clear_key) + self.btn_save.clicked.connect(self._save_key) + self.btn_close.clicked.connect(self.close) + self.textedit_api_key.textChanged.connect(self._on_text_changed) + + def _on_text_changed(self): + if self._is_visible: + self._stored_key = self.textedit_api_key.toPlainText().replace("\n", "").strip() + + def _toggle_visibility(self, show: bool): + self._is_visible = show + if show: + self.textedit_api_key.setReadOnly(False) + self.textedit_api_key.setPlainText(self._stored_key) + self.btn_show.setText("Hide") + else: + self._stored_key = self.textedit_api_key.toPlainText().replace("\n", "").strip() + self.textedit_api_key.setPlainText(_MASK_CHAR * len(self._stored_key)) + self.textedit_api_key.setReadOnly(True) + self.btn_show.setText("Show") + + def _load_key(self): + key = control._def.ANTHROPIC_API_KEY or "" + self._stored_key = key + # Start masked and read-only + self.textedit_api_key.setPlainText(_MASK_CHAR * len(key)) + self.textedit_api_key.setReadOnly(True) + + def _save_key(self): + """Save the API key to runtime config and cache file.""" + if self._is_visible: + self._stored_key = self.textedit_api_key.toPlainText().replace("\n", "").strip() + key = self._stored_key or None + + data = {"api_key": key} + try: + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + fd = os.open(CACHE_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + yaml.dump(data, f, default_flow_style=False) + control._def.ANTHROPIC_API_KEY = key + self.label_status.setText("Saved" if key else "Cleared") + self.label_status.setStyleSheet("color: green;") + log.info("Anthropic API key %s in cache", "saved" if key else "cleared") + except (OSError, yaml.YAMLError) as e: + self.label_status.setText(f"Failed to save: {e}") + self.label_status.setStyleSheet("color: red;") + log.error(f"Failed to save Anthropic API key: {e}") + + def _clear_key(self): + self._stored_key = "" + self.textedit_api_key.clear() + self._save_key() diff --git a/software/docs/mcp_integration.md b/software/docs/mcp_integration.md index 0f6ff4eb9..5f5445d00 100644 --- a/software/docs/mcp_integration.md +++ b/software/docs/mcp_integration.md @@ -27,15 +27,21 @@ This document describes how to use the Model Context Protocol (MCP) integration ### Option A: Launch from GUI (Recommended) 1. Start the Squid GUI -2. Go to **Settings → Launch Claude Code** -3. If Claude Code is not installed, you'll be prompted to install it automatically -4. A terminal will open with Claude Code running in the correct directory +2. *(Optional)* Go to **Settings → Set Anthropic API Key...** and enter your API key (get one from [console.anthropic.com](https://console.anthropic.com/settings/keys)). If you are already logged into claude.ai, you can skip this step. +3. Go to **Settings → Launch Claude Code** +4. If Claude Code is not installed, you'll be prompted to install it automatically +5. A terminal will open with Claude Code running in the correct directory This automatically: - Starts the MCP control server (on-demand) +- Passes the API key if one is set (via a temporary launcher script that keeps it out of command-line arguments; the key is set as an environment variable for the Claude Code process) - Configures the MCP connection - Pre-approves all microscope commands +**Authentication:** Claude Code supports two authentication methods: +- **claude.ai login** (OAuth) — If you are already logged in via `claude login`, no API key is needed +- **API key** — Set via **Settings → Set Anthropic API Key...**; cached locally in `cache/claude_api_key.yaml` and persists across restarts + ### On-Demand Control Server The MCP control server does **not** start automatically when the GUI launches. It starts when: @@ -266,6 +272,15 @@ The TCP protocol uses newline-delimited JSON: ## Troubleshooting +### "API Key Not Set" when launching Claude Code +- Go to **Settings → Set Anthropic API Key...** to enter your key +- Get a key from [console.anthropic.com](https://console.anthropic.com/settings/keys) +- The key is cached locally and persists across restarts + +### "Auth conflict" warning in Claude Code +- This occurs when you have both an API key and an existing claude.ai login +- Claude Code will use the API key; the warning is informational and can be ignored + ### "Cannot connect to microscope" - Ensure the Squid GUI is running - Enable the control server via **Settings → Enable MCP Control Server** (or use **Launch Claude Code** which auto-starts it) diff --git a/software/main_hcs.py b/software/main_hcs.py index 27d8247c1..54ac13522 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -31,8 +31,11 @@ if ENABLE_MCP_SERVER_SUPPORT: from control.microscope_control_server import MicroscopeControlServer + from control.widgets_claude import ClaudeApiKeyDialog, load_claude_api_key_from_cache + import shlex import subprocess import shutil + import tempfile if __name__ == "__main__": @@ -133,6 +136,9 @@ def start_control_server_if_needed(): return True return False + # Load cached Anthropic API key for Claude Code + load_claude_api_key_from_cache() + # Auto-start server if --start-server flag is provided if args.start_server: start_control_server_if_needed() @@ -193,8 +199,34 @@ def on_python_exec_toggled(checked): python_exec_action.toggled.connect(on_python_exec_toggled) settings_menu.addAction(python_exec_action) + # Add Set Anthropic API Key action + def open_api_key_dialog(): + dialog = ClaudeApiKeyDialog(parent=win) + dialog.exec_() + + api_key_action = QAction("Set Anthropic API Key...", win) + api_key_action.setToolTip("Set the API key used when launching Claude Code") + api_key_action.triggered.connect(open_api_key_dialog) + settings_menu.addAction(api_key_action) + # Add Launch Claude Code action def launch_claude_code(): + # Check for API key (optional — user may already be logged in via claude.ai) + api_key = control._def.ANTHROPIC_API_KEY + if not api_key: + reply = QMessageBox.question( + win, + "API Key Not Set", + "No Anthropic API key is configured.\n\n" + "If you are already logged into claude.ai, you can skip this.\n" + "Otherwise, would you like to set an API key now?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if reply == QMessageBox.Yes: + open_api_key_dialog() + api_key = control._def.ANTHROPIC_API_KEY + # Start control server if not running if start_control_server_if_needed(): control_server_action.setChecked(True) @@ -242,29 +274,74 @@ def launch_claude_code(): ) return + # Write a temporary launcher script so the API key never appears + # in the terminal's visible command line (e.g., in `ps` output). + # On Unix, the script deletes itself before launching claude. + # On Windows, the batch file self-deletes after claude exits. + try: + if sys.platform == "win32": + fd, script_path = tempfile.mkstemp(suffix=".bat", prefix="squid_claude_") + with os.fdopen(fd, "w") as f: + f.write("@echo off\n") + f.write("setlocal\n") + if api_key: + safe_key = api_key.replace("%", "%%").replace('"', '""') + f.write(f'set "ANTHROPIC_API_KEY={safe_key}"\n') + f.write('set "CLAUDE_MODEL=claude-opus-4-6"\n') + f.write(f'cd /d "{working_dir}"\n') + f.write("claude\n") + f.write("endlocal\n") + f.write('(goto) 2>nul & del "%~f0"\n') + else: + fd, script_path = tempfile.mkstemp(suffix=".sh", prefix="squid_claude_") + with os.fdopen(fd, "w") as f: + f.write("#!/bin/bash\n") + if api_key: + f.write(f"export ANTHROPIC_API_KEY={shlex.quote(api_key)}\n") + f.write("export CLAUDE_MODEL=claude-opus-4-6\n") + f.write(f"rm -f {shlex.quote(script_path)}\n") + f.write(f"cd {shlex.quote(working_dir)}\n") + f.write("claude\n") + # On Linux, keep the terminal open after claude exits. + # Unset API key so it doesn't linger in the interactive shell. + if sys.platform != "darwin": + f.write("unset ANTHROPIC_API_KEY\n") + f.write("exec bash\n") + os.chmod(script_path, 0o700) + except Exception as e: + try: + os.unlink(script_path) + except (OSError, NameError) as cleanup_err: + log.debug(f"Failed to remove temporary launcher script during cleanup: {cleanup_err}") + log.error(f"Failed to create launcher script: {e}") + QMessageBox.warning( + win, + "Launch Failed", + f"Failed to create launcher script:\n\n{str(e)}", + ) + return + try: if sys.platform == "darwin": # macOS - script = f""" - tell application "Terminal" - activate - do script "cd '{working_dir}' && claude" - end tell - """ + bash_cmd = f"bash {shlex.quote(script_path)}" + escaped_cmd = bash_cmd.replace("\\", "\\\\").replace('"', '\\"') + script = ( + 'tell application "Terminal"\n' + " activate\n" + f' do script "{escaped_cmd}"\n' + "end tell" + ) subprocess.Popen(["osascript", "-e", script]) elif sys.platform == "win32": # Windows - # Quote path to handle spaces - subprocess.Popen( - f'start cmd /k "cd /d \\"{working_dir}\\" && claude"', - shell=True, - ) + subprocess.Popen(f'start cmd /k "{script_path}"', shell=True) else: # Linux terminals = [ - ["gnome-terminal", "--", "bash", "-c", f'cd "{working_dir}" && claude; exec bash'], - ["konsole", "-e", "bash", "-c", f'cd "{working_dir}" && claude; exec bash'], - ["xfce4-terminal", "-e", f'bash -c "cd \\"{working_dir}\\" && claude; exec bash"'], - ["xterm", "-e", f'bash -c "cd \\"{working_dir}\\" && claude; exec bash"'], + ["gnome-terminal", "--", "bash", script_path], + ["konsole", "-e", "bash", script_path], + ["xfce4-terminal", "-e", f"bash {shlex.quote(script_path)}"], + ["xterm", "-e", f"bash {shlex.quote(script_path)}"], ] launched = False for cmd in terminals: @@ -272,20 +349,36 @@ def launch_claude_code(): subprocess.Popen(cmd) launched = True break - except FileNotFoundError: + except OSError: continue if not launched: + try: + os.unlink(script_path) + except OSError as cleanup_err: + log.warning( + f"Failed to clean up launcher script at {script_path}: {cleanup_err}. " + "This file contains the API key and should be manually deleted." + ) QMessageBox.warning( win, "Terminal Not Found", "Could not find a supported terminal emulator.\n\n" "Supported: gnome-terminal, konsole, xfce4-terminal, xterm", ) + return log.info("Launched Claude Code") except Exception as e: + # Clean up temp script on failure + try: + os.unlink(script_path) + except OSError as cleanup_err: + log.warning( + f"Failed to clean up launcher script at {script_path}: {cleanup_err}. " + "This file contains the API key and should be manually deleted." + ) log.error(f"Failed to launch Claude Code: {e}") QMessageBox.warning( win,