From 8d38b244585d5398c80a70f9e89acbae8523538c Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:17:48 -0500 Subject: [PATCH 1/9] feat: Add Anthropic API key management for Claude Code integration Add a GUI dialog (Settings > Set Anthropic API Key...) for entering and caching the Anthropic API key used when launching Claude Code. The key is stored in cache/claude_api_key.yaml, masked with bullet characters in the UI, and passed via a self-deleting temp script so it never appears in the terminal command line. Defaults Claude Code to Opus 4.6 model. Co-Authored-By: Claude Opus 4.6 --- software/control/_def.py | 1 + software/control/widgets_claude.py | 166 +++++++++++++++++++++++++++++ software/main_hcs.py | 102 +++++++++++++++--- 3 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 software/control/widgets_claude.py 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..2367eb3ca --- /dev/null +++ b/software/control/widgets_claude.py @@ -0,0 +1,166 @@ +"""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) or {} + key = data.get("api_key") + if key: + control._def.ANTHROPIC_API_KEY = key + log.info("Loaded Anthropic API key from cache") + except Exception as e: + log.warning(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().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().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) if key else "") + self.textedit_api_key.setReadOnly(True) + + def _save_key(self): + # Read current text — if visible, use it directly; if masked, use stored key + if self._is_visible: + self._stored_key = self.textedit_api_key.toPlainText().strip() + key = self._stored_key or None + control._def.ANTHROPIC_API_KEY = key + + data = {"api_key": key} + try: + os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) + with open(CACHE_FILE, "w") as f: + yaml.dump(data, f, default_flow_style=False) + 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 Exception 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() diff --git a/software/main_hcs.py b/software/main_hcs.py index 27d8247c1..deee769e3 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,36 @@ 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 + 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" + "Claude Code requires an API key to authenticate.\n" + "Would you like to set one now?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.Yes, + ) + if reply == QMessageBox.Yes: + open_api_key_dialog() + api_key = control._def.ANTHROPIC_API_KEY + if not api_key: + return + # Start control server if not running if start_control_server_if_needed(): control_server_action.setChecked(True) @@ -242,29 +276,61 @@ def launch_claude_code(): ) return + # Write a self-deleting launcher script so the API key + # never appears in the terminal's visible command line. + try: + if sys.platform == "win32": + fd, script_path = tempfile.mkstemp(suffix=".bat", prefix="squid_claude_") + safe_key = api_key.replace('"', '""') + with os.fdopen(fd, "w") as f: + f.write("@echo off\n") + 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('(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") + 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") + if sys.platform == "darwin": + f.write("claude\n") + else: + f.write("claude\n") + f.write("exec bash\n") + os.chmod(script_path, 0o700) + except Exception as e: + 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 - """ + script = ( + 'tell application "Terminal"\n' + " activate\n" + f' do script "{script_path}"\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: @@ -276,6 +342,7 @@ def launch_claude_code(): continue if not launched: + os.unlink(script_path) QMessageBox.warning( win, "Terminal Not Found", @@ -286,6 +353,11 @@ def launch_claude_code(): log.info("Launched Claude Code") except Exception as e: + # Clean up temp script on failure + try: + os.unlink(script_path) + except OSError: + pass log.error(f"Failed to launch Claude Code: {e}") QMessageBox.warning( win, From fdd42e4509b543a8b59b12353be61f208858af38 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:31:55 -0500 Subject: [PATCH 2/9] fix: Address code review findings for API key management - Restrict cache file permissions to owner-only (0o600) - Escape % in Windows batch scripts to prevent variable expansion - Escape script_path in AppleScript to prevent injection - Make Clear button persist immediately by calling save - Update runtime config only after successful disk write - Validate yaml.safe_load returns a dict before using .get() - Narrow exception handling in cache loader to expected types - Log warning when temp script cleanup fails (contains API key) - Collapse dead darwin/else branch with duplicated code - Clarify self-deleting comment with platform-specific behavior Co-Authored-By: Claude Opus 4.6 --- software/control/widgets_claude.py | 19 ++++++++++++------- software/main_hcs.py | 25 +++++++++++++++---------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/software/control/widgets_claude.py b/software/control/widgets_claude.py index 2367eb3ca..2c8c0645d 100644 --- a/software/control/widgets_claude.py +++ b/software/control/widgets_claude.py @@ -38,13 +38,16 @@ def load_claude_api_key_from_cache(): return try: with open(CACHE_FILE, "r") as f: - data = yaml.safe_load(f) or {} + data = yaml.safe_load(f) + 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: control._def.ANTHROPIC_API_KEY = key log.info("Loaded Anthropic API key from cache") - except Exception as e: - log.warning(f"Failed to load Anthropic API key from cache: {e}") + except (yaml.YAMLError, OSError) as e: + log.error(f"Failed to load Anthropic API key from cache: {e}") class ClaudeApiKeyDialog(QDialog): @@ -138,21 +141,22 @@ 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) if key else "") + self.textedit_api_key.setPlainText(_MASK_CHAR * len(key)) self.textedit_api_key.setReadOnly(True) def _save_key(self): - # Read current text — if visible, use it directly; if masked, use stored key + """Save the API key to runtime config and cache file.""" if self._is_visible: self._stored_key = self.textedit_api_key.toPlainText().strip() key = self._stored_key or None - control._def.ANTHROPIC_API_KEY = key data = {"api_key": key} try: os.makedirs(os.path.dirname(CACHE_FILE), exist_ok=True) - with open(CACHE_FILE, "w") as f: + 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") @@ -164,3 +168,4 @@ def _save_key(self): def _clear_key(self): self._stored_key = "" self.textedit_api_key.clear() + self._save_key() diff --git a/software/main_hcs.py b/software/main_hcs.py index deee769e3..9be158568 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -276,12 +276,14 @@ def launch_claude_code(): ) return - # Write a self-deleting launcher script so the API key - # never appears in the terminal's visible command line. + # 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_") - safe_key = api_key.replace('"', '""') + safe_key = api_key.replace("%", "%%").replace('"', '""') with os.fdopen(fd, "w") as f: f.write("@echo off\n") f.write(f'set "ANTHROPIC_API_KEY={safe_key}"\n') @@ -297,10 +299,9 @@ def launch_claude_code(): 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") - if sys.platform == "darwin": - f.write("claude\n") - else: - f.write("claude\n") + f.write("claude\n") + # On Linux, keep the terminal open after claude exits + if sys.platform != "darwin": f.write("exec bash\n") os.chmod(script_path, 0o700) except Exception as e: @@ -314,10 +315,11 @@ def launch_claude_code(): try: if sys.platform == "darwin": # macOS + escaped_path = script_path.replace("\\", "\\\\").replace('"', '\\"') script = ( 'tell application "Terminal"\n' " activate\n" - f' do script "{script_path}"\n' + f' do script "{escaped_path}"\n' "end tell" ) subprocess.Popen(["osascript", "-e", script]) @@ -356,8 +358,11 @@ def launch_claude_code(): # Clean up temp script on failure try: os.unlink(script_path) - except OSError: - pass + 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, From 8856e396dafb507ec95b2c632a69ae9682be6385 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:35:37 -0500 Subject: [PATCH 3/9] fix: Address second round of review findings - Validate API key from cache is a string (prevents downstream TypeError) - Narrow _save_key exception to (OSError, yaml.YAMLError) to match loader - Clean up temp file on script creation failure - Use `bash` prefix in macOS AppleScript do script (noexec tmp dirs) - Broaden Linux terminal catch to OSError (tries remaining terminals) - Add missing return after Linux "Terminal Not Found" (prevents false log) Co-Authored-By: Claude Opus 4.6 --- software/control/widgets_claude.py | 5 ++++- software/main_hcs.py | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/software/control/widgets_claude.py b/software/control/widgets_claude.py index 2c8c0645d..19143c2f3 100644 --- a/software/control/widgets_claude.py +++ b/software/control/widgets_claude.py @@ -44,6 +44,9 @@ def load_claude_api_key_from_cache(): 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: @@ -160,7 +163,7 @@ def _save_key(self): 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 Exception as e: + 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}") diff --git a/software/main_hcs.py b/software/main_hcs.py index 9be158568..077253fc2 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -305,6 +305,10 @@ def launch_claude_code(): f.write("exec bash\n") os.chmod(script_path, 0o700) except Exception as e: + try: + os.unlink(script_path) + except (OSError, NameError): + pass log.error(f"Failed to create launcher script: {e}") QMessageBox.warning( win, @@ -319,7 +323,7 @@ def launch_claude_code(): script = ( 'tell application "Terminal"\n' " activate\n" - f' do script "{escaped_path}"\n' + f' do script "bash {escaped_path}"\n' "end tell" ) subprocess.Popen(["osascript", "-e", script]) @@ -340,7 +344,7 @@ def launch_claude_code(): subprocess.Popen(cmd) launched = True break - except FileNotFoundError: + except OSError: continue if not launched: @@ -351,6 +355,7 @@ def launch_claude_code(): "Could not find a supported terminal emulator.\n\n" "Supported: gnome-terminal, konsole, xfce4-terminal, xterm", ) + return log.info("Launched Claude Code") From 6eb19e99394efdaef88c650c44f204af7d955213 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:37:43 -0500 Subject: [PATCH 4/9] fix: Protect os.unlink on Linux Terminal Not Found path Wrap os.unlink in try-except to prevent misleading "Failed to launch" error when the real issue is no terminal emulator found. Consistent with the cleanup pattern used in the outer exception handler. Co-Authored-By: Claude Opus 4.6 --- software/main_hcs.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/software/main_hcs.py b/software/main_hcs.py index 077253fc2..8ad1366e3 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -348,7 +348,13 @@ def launch_claude_code(): continue if not launched: - os.unlink(script_path) + 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", From abe455402774469aa5478904e2fdf6de2a05e0b6 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:39:37 -0500 Subject: [PATCH 5/9] docs: Update MCP integration guide with API key setup Add API key configuration step to the GUI launch instructions and add troubleshooting entries for API key and auth conflict scenarios. Co-Authored-By: Claude Opus 4.6 --- software/docs/mcp_integration.md | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/software/docs/mcp_integration.md b/software/docs/mcp_integration.md index 0f6ff4eb9..30580944c 100644 --- a/software/docs/mcp_integration.md +++ b/software/docs/mcp_integration.md @@ -27,15 +27,19 @@ 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. Go to **Settings → Set Anthropic API Key...** and enter your API key (get one from [console.anthropic.com](https://console.anthropic.com/settings/keys)) +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 securely (via a temporary launcher script, not visible in process listings) - Configures the MCP connection - Pre-approves all microscope commands +The API key is cached locally in `cache/claude_api_key.yaml` and persists across restarts. You only need to set it once. + ### On-Demand Control Server The MCP control server does **not** start automatically when the GUI launches. It starts when: @@ -266,6 +270,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) From c3797a272807e63237737d51fe4a0fdad130b44a Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:41:11 -0500 Subject: [PATCH 6/9] feat: Make API key optional for Claude Code launch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users already logged into claude.ai via OAuth don't need an API key. The launch flow now allows proceeding without one — the dialog offers to set a key but defaults to "No" (skip). The launcher script conditionally includes the API key env var only when set. Co-Authored-By: Claude Opus 4.6 --- software/docs/mcp_integration.md | 8 +++++--- software/main_hcs.py | 18 +++++++++--------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/software/docs/mcp_integration.md b/software/docs/mcp_integration.md index 30580944c..b8a813ad9 100644 --- a/software/docs/mcp_integration.md +++ b/software/docs/mcp_integration.md @@ -27,18 +27,20 @@ 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 → Set Anthropic API Key...** and enter your API key (get one from [console.anthropic.com](https://console.anthropic.com/settings/keys)) +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 securely (via a temporary launcher script, not visible in process listings) +- Passes the API key securely if one is set (via a temporary launcher script, not visible in process listings) - Configures the MCP connection - Pre-approves all microscope commands -The API key is cached locally in `cache/claude_api_key.yaml` and persists across restarts. You only need to set it once. +**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 diff --git a/software/main_hcs.py b/software/main_hcs.py index 8ad1366e3..9c83565a9 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -211,23 +211,21 @@ def open_api_key_dialog(): # Add Launch Claude Code action def launch_claude_code(): - # Check for API key + # 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" - "Claude Code requires an API key to authenticate.\n" - "Would you like to set one now?", + "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.Yes, + QMessageBox.No, ) if reply == QMessageBox.Yes: open_api_key_dialog() api_key = control._def.ANTHROPIC_API_KEY - if not api_key: - return # Start control server if not running if start_control_server_if_needed(): @@ -283,10 +281,11 @@ def launch_claude_code(): try: if sys.platform == "win32": fd, script_path = tempfile.mkstemp(suffix=".bat", prefix="squid_claude_") - safe_key = api_key.replace("%", "%%").replace('"', '""') with os.fdopen(fd, "w") as f: f.write("@echo off\n") - f.write(f'set "ANTHROPIC_API_KEY={safe_key}"\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") @@ -295,7 +294,8 @@ def launch_claude_code(): fd, script_path = tempfile.mkstemp(suffix=".sh", prefix="squid_claude_") with os.fdopen(fd, "w") as f: f.write("#!/bin/bash\n") - f.write(f"export ANTHROPIC_API_KEY={shlex.quote(api_key)}\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") From e9fff4986d524db675767bbbbeb4c32fd2dc989d Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 19:41:54 -0500 Subject: [PATCH 7/9] fix: Only set CLAUDE_MODEL when using API key CLAUDE_MODEL should not override the user's model selection when they are authenticated via OAuth (claude.ai login). Co-Authored-By: Claude Opus 4.6 --- software/main_hcs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/software/main_hcs.py b/software/main_hcs.py index 9c83565a9..2920fb8bc 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -286,7 +286,7 @@ def launch_claude_code(): 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('set "CLAUDE_MODEL=claude-opus-4-6"\n') f.write(f'cd /d "{working_dir}"\n') f.write("claude\n") f.write('(goto) 2>nul & del "%~f0"\n') @@ -296,7 +296,7 @@ def launch_claude_code(): 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("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") From 97d08ca924cd0a2530b1d3244ed6748a911effe9 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Sun, 8 Feb 2026 23:45:34 -0500 Subject: [PATCH 8/9] fix: Address PR review comments - Strip newlines from pasted API keys (prevents auth failures) - Quote script path in macOS AppleScript bash command - Set CLAUDE_MODEL unconditionally (not gated on API key) - Treat empty cache file (None) as no-op instead of error - Add log.debug on script creation cleanup failure Co-Authored-By: Claude Opus 4.6 --- software/control/widgets_claude.py | 8 +++++--- software/main_hcs.py | 13 +++++++------ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/software/control/widgets_claude.py b/software/control/widgets_claude.py index 19143c2f3..406433878 100644 --- a/software/control/widgets_claude.py +++ b/software/control/widgets_claude.py @@ -39,6 +39,8 @@ def load_claude_api_key_from_cache(): 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 @@ -126,7 +128,7 @@ def _connect_signals(self): def _on_text_changed(self): if self._is_visible: - self._stored_key = self.textedit_api_key.toPlainText().strip() + self._stored_key = self.textedit_api_key.toPlainText().replace("\n", "").strip() def _toggle_visibility(self, show: bool): self._is_visible = show @@ -135,7 +137,7 @@ def _toggle_visibility(self, show: bool): self.textedit_api_key.setPlainText(self._stored_key) self.btn_show.setText("Hide") else: - self._stored_key = self.textedit_api_key.toPlainText().strip() + 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") @@ -150,7 +152,7 @@ def _load_key(self): 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().strip() + self._stored_key = self.textedit_api_key.toPlainText().replace("\n", "").strip() key = self._stored_key or None data = {"api_key": key} diff --git a/software/main_hcs.py b/software/main_hcs.py index 2920fb8bc..56f998f21 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -286,7 +286,7 @@ def launch_claude_code(): 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('set "CLAUDE_MODEL=claude-opus-4-6"\n') f.write(f'cd /d "{working_dir}"\n') f.write("claude\n") f.write('(goto) 2>nul & del "%~f0"\n') @@ -296,7 +296,7 @@ def launch_claude_code(): 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("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") @@ -307,8 +307,8 @@ def launch_claude_code(): except Exception as e: try: os.unlink(script_path) - except (OSError, NameError): - pass + 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, @@ -319,11 +319,12 @@ def launch_claude_code(): try: if sys.platform == "darwin": # macOS - escaped_path = script_path.replace("\\", "\\\\").replace('"', '\\"') + bash_cmd = f"bash {shlex.quote(script_path)}" + escaped_cmd = bash_cmd.replace("\\", "\\\\").replace('"', '\\"') script = ( 'tell application "Terminal"\n' " activate\n" - f' do script "bash {escaped_path}"\n' + f' do script "{escaped_cmd}"\n' "end tell" ) subprocess.Popen(["osascript", "-e", script]) From f7e727f319906fd85300416de25da4775f4a8262 Mon Sep 17 00:00:00 2001 From: Hongquan Li Date: Mon, 9 Feb 2026 00:40:04 -0500 Subject: [PATCH 9/9] fix: Clear API key from env after Claude Code exits - Windows: wrap env vars in setlocal/endlocal so they don't persist in the cmd session after claude exits - Linux: unset ANTHROPIC_API_KEY before exec bash so the interactive shell doesn't retain the key - Docs: clarify that the key is kept out of command-line args but is set as an environment variable (not "not visible in process listings") Co-Authored-By: Claude Opus 4.6 --- software/docs/mcp_integration.md | 2 +- software/main_hcs.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/software/docs/mcp_integration.md b/software/docs/mcp_integration.md index b8a813ad9..5f5445d00 100644 --- a/software/docs/mcp_integration.md +++ b/software/docs/mcp_integration.md @@ -34,7 +34,7 @@ This document describes how to use the Model Context Protocol (MCP) integration This automatically: - Starts the MCP control server (on-demand) -- Passes the API key securely if one is set (via a temporary launcher script, not visible in process listings) +- 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 diff --git a/software/main_hcs.py b/software/main_hcs.py index 56f998f21..54ac13522 100644 --- a/software/main_hcs.py +++ b/software/main_hcs.py @@ -283,12 +283,14 @@ def launch_claude_code(): 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_") @@ -300,8 +302,10 @@ def launch_claude_code(): 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 + # 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: