""" mcp_manager.py -- A form-based editor for Claude Desktop MCP servers. Edit the `mcpServers` block of one or more Claude installs without ever hand-writing JSON. Validates, checks that each server's command actually exists on PATH, backs up before writing, and never touches any other key in your config. Run: python mcp_manager.py """ from __future__ import annotations import contextlib import sys import time from pathlib import Path from typing import ClassVar from PySide6.QtCore import ( QEvent, QFileSystemWatcher, QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal, ) from PySide6.QtGui import ( QAction, QActionGroup, QColor, QCursor, QDesktopServices, QGuiApplication, QIcon, QKeySequence, QPainter, QPalette, QPixmap, ) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, QSpinBox, QSplitter, QStackedWidget, QStyledItemDelegate, QTableWidget, QTableWidgetItem, QToolTip, QVBoxLayout, QWidget, ) import bcc_core as core # A full ~/.claude.json with history can be huge; parsing happens on the UI # thread during drag-and-drop import, so skip anything larger than this. MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB # --- Theming (issue #75) -------------------------------------------------- # # The palette lives in bcc_core (testable without a Qt app); these module-level # names are rebound by `apply_palette()` whenever the theme changes. # # Why globals rather than passing a palette around: ~20 inline # `setStyleSheet(f"color: {MUTED}")` calls are scattered through this file, and # an f-string resolves its names when it runs, not when it's compiled. Rebinding # the globals means every one of those call sites picks up the new colour on its # next render, with no change to the call sites themselves. PALETTE = core.DARK_PALETTE ACCENT = ACCENT_DIM = BG = PANEL = PANEL_2 = TEXT = MUTED = BORDER = "" GOOD = BAD = WARN = REMOTE = ON_ACCENT = DISABLED_BG = MONO_BG = SEL_TEXT = "" STATUS_COLORS: dict[str, str] = {} HEALTH_COLORS: dict[str, str] = {} STATUS_GLYPH = { "ok": "\u25cf", "missing": "\u25cf", "warn": "\u25b2", "remote": "\u25c6", "unknown": "\u25cb", } # Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the # server tables' "Health" column -- distinct from the PATH-dependency Status # column above. HEALTH_GLYPH = {"ok": "\u25cf", "failed": "\u25cf", "untested": "\u25cb"} def build_stylesheet(p: core.Palette) -> str: """Render the global QSS for a palette.""" return f""" /* No font-family here on purpose: Qt already uses the native system UI font on every platform (San Francisco / Segoe UI / desktop default). Naming web-CSS aliases like -apple-system forces a costly font-alias scan. */ * {{ font-size: 13px; color: {p.text}; }} QMainWindow, QDialog {{ background: {p.bg}; }} QLabel#h1 {{ font-size: 15px; font-weight: 600; }} QLabel#muted {{ color: {p.muted}; }} QFrame#card {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px; }} QLineEdit, QPlainTextEdit, QComboBox {{ background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px; padding: 6px 8px; selection-background-color: {p.accent}; selection-color: {p.on_accent}; }} QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {p.accent}; }} QComboBox::drop-down {{ border: none; width: 22px; }} QComboBox QAbstractItemView {{ background: {p.panel_2}; border: 1px solid {p.border}; selection-background-color: {p.accent}; outline: none; }} QPushButton {{ background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px; padding: 7px 13px; }} QPushButton:hover {{ border: 1px solid {p.accent}; }} QPushButton:disabled {{ color: {p.muted}; background: {p.panel}; }} QPushButton#primary {{ background: {p.accent}; border: 1px solid {p.accent}; color: {p.on_accent}; font-weight: 600; }} QPushButton#primary:hover {{ background: {p.accent_dim}; }} QPushButton#primary:disabled {{ background: {p.panel}; color: {p.muted}; border: 1px solid {p.border}; }} QPushButton#danger:hover {{ border: 1px solid {p.bad}; color: {p.bad}; }} QTableWidget {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px; gridline-color: transparent; outline: none; }} QTableWidget::item {{ padding: 6px 8px; border: none; }} QTableWidget::item:selected {{ background: {p.accent}; color: {p.on_accent}; }} /* Inline cell editors: the global QLineEdit padding/radius clips the text inside a table row, so give editors a compact, flat style instead. */ QTableWidget QLineEdit {{ background: {p.panel_2}; color: {p.text}; border: 1px solid {p.accent}; border-radius: 3px; padding: 0px 4px; margin: 0px; selection-background-color: {p.accent_dim}; selection-color: {p.selection_text}; }} QHeaderView::section {{ background: {p.panel}; color: {p.muted}; border: none; border-bottom: 1px solid {p.border}; padding: 8px; font-weight: 600; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} QScrollBar::handle:vertical {{ background: {p.border}; border-radius: 5px; min-height: 24px; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QLabel#statusbar {{ color: {p.muted}; padding: 4px 2px; }} QLabel#warnBanner {{ color: {p.on_accent}; background: {p.warn}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }} QFrame#noticeBanner {{ background: {p.panel_2}; border: 1px solid {p.accent}; border-radius: 8px; }} QLabel#noticeText {{ color: {p.text}; }} QPushButton#noticeClose {{ background: transparent; border: none; color: {p.muted}; font-size: 14px; padding: 2px; }} QPushButton#noticeClose:hover {{ color: {p.text}; }} QLabel#section {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#sectionDisabled {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#placeholder {{ color: {p.muted}; padding: 12px; background: {p.panel_2}; border: 1px dashed {p.border}; border-radius: 8px; }} QTableWidget#disabledTable {{ background: {p.disabled_bg}; }} QTableWidget#disabledTable::item:selected {{ background: {p.accent}; color: {p.on_accent}; }} QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; font-size: 12px; background: {p.mono_bg}; border: 1px solid {p.border}; border-radius: 8px; }} QFrame#diagCard {{ background: transparent; border: none; }} QSplitter::handle {{ background: transparent; }} QSplitter::handle:hover {{ background: {p.border}; border-radius: 4px; }} QSplitter::handle:pressed {{ background: {p.accent}; border-radius: 4px; }} """ def apply_palette(p: core.Palette) -> str: """Rebind the module-level colour names to `p` and return its stylesheet.""" global PALETTE, ACCENT, ACCENT_DIM, BG, PANEL, PANEL_2, TEXT, MUTED, BORDER global GOOD, BAD, WARN, REMOTE, ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT global STATUS_COLORS, HEALTH_COLORS PALETTE = p ACCENT, ACCENT_DIM = p.accent, p.accent_dim BG, PANEL, PANEL_2 = p.bg, p.panel, p.panel_2 TEXT, MUTED, BORDER = p.text, p.muted, p.border GOOD, BAD, WARN, REMOTE = p.good, p.bad, p.warn, p.remote ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT = ( p.on_accent, p.disabled_bg, p.mono_bg, p.selection_text, ) STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": REMOTE, "unknown": WARN} HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED} return build_stylesheet(p) STYLESHEET = apply_palette(core.DARK_PALETTE) # --------------------------------------------------------------------------- # # Background reachability tester (keeps the UI responsive during the request) # --------------------------------------------------------------------------- # class ConnTester(QThread): done = Signal(bool, str) def __init__(self, url, timeout=5.0): super().__init__() self.url = url self.timeout = timeout def run(self): ok, detail = core.test_remote(self.url, self.timeout) self.done.emit(ok, detail) class SpawnTester(QThread): done = Signal(dict) def __init__(self, data, timeout=3.0): super().__init__() self._data = data self._timeout = timeout def run(self): result = core.spawn_test(self._data, self._timeout) self.done.emit(result) class UpdateCheckWorker(QThread): """ Fetches the latest release from the Gitea releases API off the UI thread. Notify-only: `core.fetch_latest_release()` only ever reads release metadata (a version tag + a URL) and never downloads or replaces the running binary. Fails quiet — emits None on any network problem — so it's safe to fire unattended from a silent startup check as well as from the About dialog's "Check for updates" button. Lifetime: the class keeps every instance alive in `_live` until its thread has finished. Without this, a caller whose own reference dies early (the About dialog is a temporary — closing it mid-check used to GC the dialog and the running QThread with it) crashes the process with "QThread: Destroyed while thread is still running". Callers may drop their reference at any time; signal connections to a destroyed receiver are disconnected by Qt, so a late result is simply discarded. """ done = Signal(object) # dict | None _live: ClassVar[set[UpdateCheckWorker]] = set() def __init__(self): super().__init__() UpdateCheckWorker._live.add(self) self.finished.connect(self._release_keepalive) def _release_keepalive(self): # Delivered on the main thread after run() has returned; only now is # it safe for the last reference to drop. UpdateCheckWorker._live.discard(self) def run(self): self.done.emit(core.fetch_latest_release()) # --------------------------------------------------------------------------- # # Small reusable: key/value editor (for env and headers) # --------------------------------------------------------------------------- # class _SecretMaskDelegate(QStyledItemDelegate): """Masks the DISPLAY of values whose key looks like a secret (API_KEY, TOKEN, ...). The underlying item text stays real, so editing, dump() and save are untouched — only what's painted on screen changes.""" def __init__(self, table): super().__init__(table) self._table = table self.revealed = False def initStyleOption(self, option, index): super().initStyleOption(option, index) if self.revealed or not option.text: return key_item = self._table.item(index.row(), 0) if key_item and core.should_mask_value(key_item.text(), option.text): option.text = core.MASK class MoveToEnvDialog(QDialog): """Confirm moving a plaintext secret out to a ${VAR} reference (#83). The secret is about to leave the config file, so this dialog's whole job is to hand it back first: it lets the user name the variable, shows the exact shell line to set it, and (on accept) the caller copies the secret to the clipboard. If the variable already looks set in this environment, it says so and drops the urgency. """ def __init__(self, parent, key: str, secret: str): super().__init__(parent) self.setWindowTitle("Move to environment variable") self.setMinimumWidth(460) self._secret = secret v = QVBoxLayout(self) v.setSpacing(10) intro = QLabel( "This replaces the value in place with a ${VAR} reference. The secret " "moves to your shell/OS environment — not this config file, and not the " "Environment variables table below. Run the line below to set it there, " "or the server won't authenticate." ) intro.setWordWrap(True) v.addWidget(intro) grid = QGridLayout() grid.setSpacing(8) lbl = QLabel("Variable:") lbl.setObjectName("muted") grid.addWidget(lbl, 0, 0) self._name_edit = QLineEdit(core.sanitize_env_var_name(key)) self._name_edit.textChanged.connect(self._refresh) grid.addWidget(self._name_edit, 0, 1) v.addLayout(grid) self._already = QLabel("") self._already.setWordWrap(True) self._already.setStyleSheet(f"color: {GOOD};") v.addWidget(self._already) set_lbl = QLabel("Set it with:") set_lbl.setObjectName("muted") v.addWidget(set_lbl) self._cmd = QLabel("") self._cmd.setWordWrap(True) self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._cmd.setStyleSheet("font-family: monospace;") v.addWidget(self._cmd) note = QLabel("The secret will be copied to your clipboard when you continue.") note.setObjectName("muted") note.setWordWrap(True) v.addWidget(note) btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) ok = btns.button(QDialogButtonBox.StandardButton.Ok) ok.setText("Move && copy secret") ok.setObjectName("primary") btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) v.addWidget(btns) self._refresh() def var_name(self) -> str: return core.sanitize_env_var_name(self._name_edit.text()) def _refresh(self, *_): name = self.var_name() lines = core.shell_export_lines(name, self._secret) if sys.platform == "win32": self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})") else: self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})") if core.is_env_var_set(name): self._already.setText(f"{name} already looks set in this environment.") self._already.show() else: self._already.hide() class MoveArgToEnvDialog(QDialog): """Confirm relocating a secret arg into the env block (#83, kept in file). Unlike the reference move, this keeps the value in the config -- it just moves it out of the argument list (visible in process listings) and into the Environment variables table, where the user can see and edit it. It changes how the server is launched, so it says so plainly. """ def __init__(self, parent, key: str, value: str): super().__init__(parent) self.setWindowTitle("Move into environment variables") self.setMinimumWidth(460) v = QVBoxLayout(self) v.setSpacing(10) intro = QLabel( "This moves the secret out of the arguments and into the Environment " "variables table below, where you can see and edit its value. The value " "stays in this config file." ) intro.setWordWrap(True) v.addWidget(intro) warn = QLabel( "⚠ This changes how the server is launched: the flag is dropped and the " "value is set as an environment variable instead. It only works if the " "server reads this secret from that variable." ) warn.setWordWrap(True) warn.setStyleSheet(f"color: {WARN};") v.addWidget(warn) grid = QGridLayout() grid.setSpacing(8) lbl = QLabel("Variable:") lbl.setObjectName("muted") grid.addWidget(lbl, 0, 0) self._name_edit = QLineEdit(core.sanitize_env_var_name(key)) grid.addWidget(self._name_edit, 0, 1) v.addLayout(grid) btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) ok = btns.button(QDialogButtonBox.StandardButton.Ok) ok.setText("Move into env") ok.setObjectName("primary") btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) v.addWidget(btns) def var_name(self) -> str: return core.sanitize_env_var_name(self._name_edit.text()) class ReferencedVarsDialog(QDialog): """Show every ${VAR} the loaded server references and whether it's set (#83). After a secret becomes a reference, the variable lives in the user's environment, not the config -- so this is where they confirm it exists and get the command to set it. Read-only; BCC can't (and shouldn't) store the value. """ def __init__(self, parent, data: dict): super().__init__(parent) self.setWindowTitle("Referenced variables") self.setMinimumWidth(560) v = QVBoxLayout(self) v.setSpacing(10) self._usages = core.referenced_env_vars(data) if not self._usages: v.addWidget(QLabel("This server references no ${VAR} variables.")) btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btns.rejected.connect(self.reject) btns.accepted.connect(self.accept) v.addWidget(btns) return intro = QLabel( "These references are read from your shell/OS environment when the client " "runs. ✓ means it's set in BCC's environment (which may differ from the " "client's) or has a default; ✗ means nothing would fill it." ) intro.setWordWrap(True) v.addWidget(intro) self._table = QTableWidget(len(self._usages), 3) self._table.setHorizontalHeaderLabels(["Variable", "Status", "Used in"]) self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) self._table.verticalHeader().setVisible(False) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) for r, u in enumerate(self._usages): is_set = core.is_env_var_set(u.name) status = "✓ set" if is_set else ("✓ default" if u.has_default else "✗ not set") self._table.setItem(r, 0, QTableWidgetItem(u.name)) self._table.setItem(r, 1, QTableWidgetItem(status)) self._table.setItem(r, 2, QTableWidgetItem(", ".join(u.fields))) self._table.selectionModel().selectionChanged.connect(self._refresh_cmd) v.addWidget(self._table, 1) set_lbl = QLabel("Set the selected variable with:") set_lbl.setObjectName("muted") v.addWidget(set_lbl) self._cmd = QLabel("") self._cmd.setWordWrap(True) self._cmd.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self._cmd.setStyleSheet("font-family: monospace;") v.addWidget(self._cmd) btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btns.rejected.connect(self.reject) btns.accepted.connect(self.accept) v.addWidget(btns) self._table.selectRow(0) def _refresh_cmd(self, *_): rows = self._table.selectionModel().selectedRows() if not rows: self._cmd.setText("") return name = self._usages[rows[0].row()].name # A placeholder value -- BCC doesn't hold the secret, this shows the shape. lines = core.shell_export_lines(name, "") if sys.platform == "win32": self._cmd.setText(f"{lines['windows']}\n\n(macOS/Linux: {lines['posix']})") else: self._cmd.setText(f"{lines['posix']}\n\n(Windows: {lines['windows']})") class KeyValueTable(QWidget): def __init__(self, key_label="Key", val_label="Value", on_change=None, before_change=None): super().__init__() self._on_change = on_change self._before_change = before_change self._key_label = key_label self._val_label = val_label lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(6) self.table = QTableWidget(0, 2) self.table.setHorizontalHeaderLabels([key_label, val_label]) self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) self.table.verticalHeader().setVisible(False) # Rows tall enough that the inline editor doesn't crush the text. self.table.verticalHeader().setDefaultSectionSize(34) self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.table.setMinimumHeight(90) self.table.itemChanged.connect(self._changed) # Right-click a secret row to move it out to a ${VAR} reference (#83). # Set by the owner (ServerEditor) so the action can gate on the client. self.profile_provider = None self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.table.customContextMenuRequested.connect(self._context_menu) # Secret-looking values (API_KEY, TOKEN, ...) render masked by default. self._mask_delegate = _SecretMaskDelegate(self.table) self.table.setItemDelegateForColumn(1, self._mask_delegate) lay.addWidget(self.table, 1) row = QHBoxLayout() add = QPushButton("+ Add") rem = QPushButton("− Remove") add.clicked.connect(self._add_row) rem.clicked.connect(self._remove_row) self.reveal_btn = QPushButton("Show secrets") self.reveal_btn.setCheckable(True) self.reveal_btn.toggled.connect(self._toggle_reveal) row.addWidget(add) row.addWidget(rem) row.addStretch() row.addWidget(self.reveal_btn) lay.addLayout(row) def _toggle_reveal(self, on): self._mask_delegate.revealed = on self.reveal_btn.setText("Hide secrets" if on else "Show secrets") self.table.viewport().update() def _row_key_value(self, row: int): key_item = self.table.item(row, 0) val_item = self.table.item(row, 1) key = key_item.text().strip() if key_item else "" # The mask is display-only (a delegate); the model text is the real value. value = val_item.text() if val_item else "" return key, value def _context_menu(self, pos): item = self.table.itemAt(pos) if item is None: return row = item.row() key, value = self._row_key_value(row) # Only a real stored secret is worth moving; nothing to offer otherwise. if not core.should_mask_value(key, value): return profile = self.profile_provider() if self.profile_provider else None menu = QMenu(self) act = QAction("Replace with a ${VAR} reference (out of file)…", self) if core.can_move_value_to_env_ref(key, value, profile): act.triggered.connect(lambda: self._move_row_to_env(row)) else: # Show it disabled with the reason rather than an empty menu, so the # feature is discoverable and Claude Desktop's gating is explained. act.setEnabled(False) act.setText("Replace with ${VAR} reference — unavailable for Claude Desktop") act.setToolTip( "Claude Desktop doesn't expand ${VAR}, so a reference would reach " "the server as literal text." ) menu.addAction(act) menu.exec(self.table.viewport().mapToGlobal(pos)) def _move_row_to_env(self, row: int): key, secret = self._row_key_value(row) if not secret: return dlg = MoveToEnvDialog(self.window(), key, secret) if not dlg.exec(): return var_name = dlg.var_name() # Hand the secret back before it leaves the file: clipboard now holds it, # and the dialog showed the exact shell line to set it. QGuiApplication.clipboard().setText(secret) if self._before_change: self._before_change() val_item = self.table.item(row, 1) if val_item is not None: # setText fires itemChanged -> _changed -> on_change (dirty + revalidate). val_item.setText(f"${{{var_name}}}") def _changed(self, item=None, *_): if item is not None and item.column() == 0: new_key = item.text().strip() row = item.row() if new_key and self._is_duplicate_key(new_key, row): prev_key = item.data(Qt.ItemDataRole.UserRole) prev_key = prev_key if prev_key is not None else "" self.table.blockSignals(True) item.setText(prev_key) self.table.blockSignals(False) QToolTip.showText(QCursor.pos(), f"Duplicate key '{new_key}' — reverted.") return item.setData(Qt.ItemDataRole.UserRole, new_key) if self._on_change: self._on_change() def _is_duplicate_key(self, key: str, ignore_row: int) -> bool: for r in range(self.table.rowCount()): if r == ignore_row: continue other = self.table.item(r, 0) if other and other.text().strip() == key: return True return False def _add_row(self): dlg = QDialog(self.window()) dlg.setWindowTitle(f"Add {self._key_label}") dlg.setMinimumWidth(340) v = QVBoxLayout(dlg) v.setSpacing(10) grid = QGridLayout() grid.setSpacing(8) lbl_k = QLabel(f"{self._key_label}:") lbl_k.setObjectName("muted") grid.addWidget(lbl_k, 0, 0) key_edit = QLineEdit() key_edit.setPlaceholderText( "e.g. API_KEY" if self._key_label == "Variable" else f"{self._key_label.lower()}-name" ) grid.addWidget(key_edit, 0, 1) lbl_v = QLabel(f"{self._val_label}:") lbl_v.setObjectName("muted") grid.addWidget(lbl_v, 1, 0) val_edit = QLineEdit() val_edit.setPlaceholderText("value") grid.addWidget(val_edit, 1, 1) v.addLayout(grid) # Shown when the typed key already exists in the table. dup_warn = QLabel("") dup_warn.setStyleSheet(f"color: {WARN};") dup_warn.setWordWrap(True) dup_warn.hide() v.addWidget(dup_warn) # Type an API_KEY/TOKEN-style name and the value field masks itself. def _sync_echo(text): secret = core.is_secret_key(text) val_edit.setEchoMode( QLineEdit.EchoMode.Password if secret else QLineEdit.EchoMode.Normal ) key_edit.textChanged.connect(_sync_echo) btns = QDialogButtonBox( QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel ) ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok) ok_btn.setObjectName("primary") ok_btn.setEnabled(False) def _validate(text): k = text.strip() dup = bool(k) and self._is_duplicate_key(k, ignore_row=-1) ok_btn.setEnabled(bool(k) and not dup) dup_warn.setText(f"'{k}' already exists" if dup else "") dup_warn.setVisible(dup) key_edit.textChanged.connect(_validate) btns.accepted.connect(dlg.accept) btns.rejected.connect(dlg.reject) v.addWidget(btns) key_edit.setFocus() # Enter in key field moves to value; Enter in value field submits key_edit.returnPressed.connect(val_edit.setFocus) val_edit.returnPressed.connect(lambda: ok_btn.click() if ok_btn.isEnabled() else None) if dlg.exec() != QDialog.DialogCode.Accepted: return k = key_edit.text().strip() val = val_edit.text() if not k or self._is_duplicate_key(k, ignore_row=-1): return if self._before_change: self._before_change() r = self.table.rowCount() self.table.insertRow(r) key_item = QTableWidgetItem(k) key_item.setData(Qt.ItemDataRole.UserRole, k) self.table.setItem(r, 0, key_item) self.table.setItem(r, 1, QTableWidgetItem(val)) self._changed() def _remove_row(self): r = self.table.currentRow() if r < 0: return if self._before_change: self._before_change() self.table.removeRow(r) self._changed() def load(self, d: dict): self.table.blockSignals(True) self.table.setRowCount(0) for k, v in (d or {}).items(): r = self.table.rowCount() self.table.insertRow(r) key_item = QTableWidgetItem(str(k)) key_item.setData(Qt.ItemDataRole.UserRole, str(k)) self.table.setItem(r, 0, key_item) self.table.setItem(r, 1, QTableWidgetItem(str(v))) self.table.blockSignals(False) def dump(self) -> dict: out = {} for r in range(self.table.rowCount()): k = self.table.item(r, 0) v = self.table.item(r, 1) k = k.text().strip() if k else "" v = v.text() if v else "" if k: out[k] = v return out # --------------------------------------------------------------------------- # # The server editor panel (right side) # --------------------------------------------------------------------------- # class ServerEditor(QFrame): def __init__(self, on_change, before_change=None): super().__init__() self.setObjectName("card") self._on_change = on_change self._before_change = before_change # called before any row add/remove in tables self._extra = {} # preserve unknown server fields verbatim self._loading = False self._last_spawn: dict | None = None # most recent spawn_test result outer = QVBoxLayout(self) outer.setContentsMargins(16, 16, 16, 16) outer.setSpacing(12) title = QLabel("Server details") title.setObjectName("h1") outer.addWidget(title) grid = QGridLayout() grid.setVerticalSpacing(10) grid.setHorizontalSpacing(10) grid.addWidget(self._lbl("Name"), 0, 0) self.name = QLineEdit() self.name.setPlaceholderText("e.g. filesystem") self.name.textChanged.connect(self._emit) grid.addWidget(self.name, 0, 1) grid.addWidget(self._lbl("Type"), 1, 0) self.type = QComboBox() self.type.addItems(["Local command (stdio)", "Remote (url)"]) self.type.currentIndexChanged.connect(self._type_switched) grid.addWidget(self.type, 1, 1) outer.addLayout(grid) # Stacked: stdio page / remote page self.stack = QStackedWidget() self.stack.addWidget(self._build_stdio_page()) self.stack.addWidget(self._build_remote_page()) outer.addWidget(self.stack, 1) # Dependency status row dep = QHBoxLayout() self.dep_dot = QLabel("○") self.dep_label = QLabel("—") self.dep_label.setObjectName("muted") self.fix_btn = QPushButton("Use full path ↳") self.fix_btn.setToolTip( "Rewrite the bare command to its absolute path so any launch of Claude can find it" ) self.fix_btn.clicked.connect(self._pin_path) self.fix_btn.setVisible(False) self.test_btn = QPushButton("Test connection") self.test_btn.clicked.connect(self._test_remote) self.test_btn.setVisible(False) self.spawn_btn = QPushButton("Test launch") self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") self.spawn_btn.clicked.connect(self._test_spawn) self.spawn_btn.setVisible(False) self.logs_btn = QPushButton("View logs") self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer") self.logs_btn.clicked.connect(self._view_logs) self.vars_btn = QPushButton("Variables…") self.vars_btn.setToolTip( "Show the ${VAR} references this server uses and whether each is set in " "your environment" ) self.vars_btn.clicked.connect(self._show_referenced_vars) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) recheck = QPushButton("Re-check") recheck.clicked.connect(self._recheck) dep.addWidget(self.dep_dot) dep.addWidget(self.dep_label, 1) dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) dep.addWidget(self.logs_btn) dep.addWidget(self.vars_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) # Version status row (#92): for an npx-style server, show the currently # resolved version and, when the spec is unpinned, a one-click "pin". # Mirrors the dependency-status surface above. Hidden for everything else. ver = QHBoxLayout() self.ver_dot = QLabel("○") self.ver_label = QLabel("—") self.ver_label.setObjectName("muted") self.ver_label.setWordWrap(True) self.pin_btn = QPushButton("Pin") self.pin_btn.setToolTip( "Pin the package spec to the currently-resolved version so it can't " "change under you on the next launch" ) self.pin_btn.clicked.connect(self._pin_version) self.pin_btn.setVisible(False) ver.addWidget(self.ver_dot) ver.addWidget(self.ver_label, 1) ver.addWidget(self.pin_btn) self.ver_row_widgets = (self.ver_dot, self.ver_label, self.pin_btn) outer.addLayout(ver) # Collapsible diagnostics panel self.diag_card = QFrame() self.diag_card.setObjectName("diagCard") dlay = QVBoxLayout(self.diag_card) dlay.setContentsMargins(0, 0, 0, 0) dlay.setSpacing(6) self.diag_text = QPlainTextEdit() self.diag_text.setObjectName("diag") self.diag_text.setReadOnly(True) self.diag_text.setMaximumHeight(190) dlay.addWidget(self.diag_text) crow = QHBoxLayout() crow.addStretch() self.copy_diag_btn = QPushButton("Copy diagnostics") self.copy_diag_btn.clicked.connect(self._copy_diag) crow.addWidget(self.copy_diag_btn) dlay.addLayout(crow) self.diag_card.setVisible(False) outer.addWidget(self.diag_card) self.setEnabled(False) def _lbl(self, t): lbl = QLabel(t) lbl.setObjectName("muted") return lbl def _build_stdio_page(self): w = QWidget() v = QVBoxLayout(w) v.setContentsMargins(0, 0, 0, 0) v.setSpacing(8) v.addWidget(self._lbl("Command")) self.command = QLineEdit() self.command.setPlaceholderText("npx, uvx, node, python3, or a full path") self.command.textChanged.connect(self._emit) v.addWidget(self.command) args_lbl = self._lbl( "Arguments — each line is one argument. A flag and its value go on " "separate lines, e.g. “--directory” on line 1, “/path/to/server” on line 2." ) args_lbl.setWordWrap(True) v.addWidget(args_lbl) self.args = ArgsEdit() self.args.setPlaceholderText("--directory\n/path/to/your/server\nrun\nmain.py") self.args.setMinimumHeight(70) self.args.textChanged.connect(self._emit) v.addWidget(self.args, 1) # Shown when a line looks like several arguments typed together. self.args_warn = QLabel("") self.args_warn.setStyleSheet(f"color: {WARN};") self.args_warn.setWordWrap(True) self.args_warn.hide() self.args_fix_btn = QPushButton("Fix: split onto separate lines") self.args_fix_btn.clicked.connect(self._fix_args) self.args_fix_btn.hide() warn_row = QHBoxLayout() warn_row.addWidget(self.args_warn, 1) warn_row.addWidget(self.args_fix_btn) v.addLayout(warn_row) # Shown when an arg value looks like a raw credential. self.secret_warn = QLabel("") self.secret_warn.setStyleSheet(f"color: {WARN};") self.secret_warn.setWordWrap(True) self.secret_warn.hide() v.addWidget(self.secret_warn) # Shown when args carry a CLI flag a package removed in a major version # (e.g. ssh-mcp v2's --password), with a one-click move into env. self.removed_flag_warn = QLabel("") self.removed_flag_warn.setStyleSheet(f"color: {WARN};") self.removed_flag_warn.setWordWrap(True) self.removed_flag_warn.hide() self.removed_flag_fix_btn = QPushButton("Fix: move to environment variables") self.removed_flag_fix_btn.clicked.connect(self._fix_removed_flags) self.removed_flag_fix_btn.hide() rf_row = QHBoxLayout() rf_row.addWidget(self.removed_flag_warn, 1) rf_row.addWidget(self.removed_flag_fix_btn) v.addLayout(rf_row) # Shown when a server reads a config sidecar (ssh-mcp's TOML): the args # may be inert, or a file may sit at the README path the server never # reads. Advisory (#91); #102 adds an "Edit config…" button that opens # the in-app sidecar editor whenever the server has a resolvable sidecar. self.sidecar_warn = QLabel("") self.sidecar_warn.setStyleSheet(f"color: {WARN};") self.sidecar_warn.setWordWrap(True) self.sidecar_warn.hide() self.sidecar_edit_btn = QPushButton("Edit config…") self.sidecar_edit_btn.setToolTip( "Open this server's external config file in BCC (edit → atomic save " "with backup → the file is tightened to 0600)" ) self.sidecar_edit_btn.clicked.connect(self._edit_sidecar) self.sidecar_edit_btn.hide() sc_row = QHBoxLayout() sc_row.addWidget(self.sidecar_warn, 1) sc_row.addWidget(self.sidecar_edit_btn, 0, Qt.AlignmentFlag.AlignTop) v.addLayout(sc_row) # Shown when the sidecar config is group/other-accessible (#93): ssh-mcp # refuses to start unless it's 0600 / its dir 0700. One-click chmod fix. # POSIX only — hidden on Windows where modes don't apply. self.perm_warn = QLabel("") self.perm_warn.setStyleSheet(f"color: {WARN};") self.perm_warn.setWordWrap(True) self.perm_warn.hide() self.perm_fix_btn = QPushButton("Fix permissions") self.perm_fix_btn.setToolTip("chmod the config file to 0600 and its directory to 0700") self.perm_fix_btn.clicked.connect(self._fix_permissions) self.perm_fix_btn.hide() perm_row = QHBoxLayout() perm_row.addWidget(self.perm_warn, 1) perm_row.addWidget(self.perm_fix_btn) v.addLayout(perm_row) v.addWidget(self._lbl("Environment variables")) self.env = KeyValueTable( "Variable", "Value", on_change=self._emit, before_change=self._before_change ) v.addWidget(self.env, 1) return w def _build_remote_page(self): w = QWidget() v = QVBoxLayout(w) v.setContentsMargins(0, 0, 0, 0) v.setSpacing(8) v.addWidget(self._lbl("URL")) self.url = QLineEdit() self.url.setPlaceholderText("https://mcp.example.com/sse") self.url.textChanged.connect(self._emit) v.addWidget(self.url) v.addWidget(self._lbl("Transport")) self.transport = QComboBox() self.transport.addItems(["(auto)", "http", "sse"]) self.transport.currentIndexChanged.connect(self._emit) v.addWidget(self.transport) v.addWidget(self._lbl("Headers")) self.headers = KeyValueTable( "Header", "Value", on_change=self._emit, before_change=self._before_change ) v.addWidget(self.headers, 1) return w def set_profile_provider(self, provider): """Let the env/headers tables and the args editor gate the secret-move actions on which client the loaded profile targets (#83), and wire the args editor's two move actions back to this editor (which owns the whole form, since moving an arg into env touches both fields).""" self.env.profile_provider = provider self.headers.profile_provider = provider self.args.profile_provider = provider self.args.on_move_to_ref = self.move_arg_to_reference self.args.on_move_to_env = self.move_arg_into_env # --- secret moves from args (#83) ------------------------------------ # def _reload_from_data(self, new_data: dict): """Repopulate the form from a transformed data dict and mark dirty.""" self.load_entry(core.ServerEntry(self.current_name(), new_data, True)) self._emit() def move_arg_to_reference(self, index: int): """Args secret -> ${VAR} reference in place (secret leaves the file).""" data = self.dump_data() args = data.get("args") or [] if not (0 <= index < len(args)): return dlg = MoveToEnvDialog( self.window(), core.suggested_env_var_for_arg(args, index), args[index] ) if not dlg.exec(): return conv = core.move_value_to_env_ref(data, field="args", index=index, var_name=dlg.var_name()) if conv is None: return QGuiApplication.clipboard().setText(conv.secret) self._reload_from_data(conv.data) def move_arg_into_env(self, index: int): """Args secret -> env block, kept in this config (visible/editable).""" data = self.dump_data() args = data.get("args") or [] if not (0 <= index < len(args)): return default_name = core.suggested_env_var_for_arg(args, index) dlg = MoveArgToEnvDialog(self.window(), default_name, args[index]) if not dlg.exec(): return new = core.move_arg_to_env_block(data, index, var_name=dlg.var_name()) if new is None: return self._reload_from_data(new) def _show_referenced_vars(self): ReferencedVarsDialog(self.window(), self.dump_data()).exec() # --- model <-> form -------------------------------------------------- # def load_entry(self, entry: core.ServerEntry | None): self._loading = True if entry is None: self.setEnabled(False) self.name.clear() self.command.clear() self.args.clear() self.url.clear() self.env.load({}) self.headers.load({}) self.details_btn.setChecked(False) self.diag_text.clear() self._last_spawn = None self._set_dep({"status": "unknown", "label": "—"}) self.args_warn.hide() self.args_fix_btn.hide() self.secret_warn.hide() self.removed_flag_warn.hide() self.removed_flag_fix_btn.hide() self.sidecar_warn.hide() self.perm_warn.hide() self.perm_fix_btn.hide() self._loading = False return self.setEnabled(True) d = entry.data self._extra = {k: v for k, v in d.items() if k not in core.KNOWN_FIELDS} self.name.setText(entry.name) # Reset BOTH pages first so a previously-selected server's values can't # leak in if the user flips the type toggle mid-edit. self.command.clear() self.args.clear() self.env.load({}) self.url.clear() self.transport.setCurrentIndex(0) self.headers.load({}) if entry.kind == "remote": self.type.setCurrentIndex(1) self.stack.setCurrentIndex(1) self.url.setText(str(d.get("url", ""))) t = d.get("type") self.transport.setCurrentText(t if t in ("http", "sse") else "(auto)") self.headers.load(d.get("headers", {})) else: self.type.setCurrentIndex(0) self.stack.setCurrentIndex(0) self.command.setText(str(d.get("command", ""))) self.args.setPlainText("\n".join(str(a) for a in (d.get("args") or []))) self.env.load(d.get("env", {})) self._loading = False self.refresh_dependency(auto_open=True) self._check_args() def dump_data(self) -> dict: """Build the server dict from the form, preserving unknown fields.""" data = dict(self._extra) if self.type.currentIndex() == 1: # remote data["url"] = self.url.text().strip() t = self.transport.currentText() if t in ("http", "sse"): data["type"] = t else: data.pop("type", None) headers = self.headers.dump() if headers: data["headers"] = headers else: data.pop("headers", None) for k in ("command", "args", "env"): data.pop(k, None) else: # stdio data["command"] = self.command.text().strip() args = [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""] if args: data["args"] = args else: data.pop("args", None) env = self.env.dump() if env: data["env"] = env else: data.pop("env", None) for k in ("url", "type", "headers"): data.pop(k, None) return data def current_name(self) -> str: return self.name.text().strip() def _type_switched(self): self.stack.setCurrentIndex(self.type.currentIndex()) self._emit() def _emit(self): if not self._loading and self._on_change: self._on_change() self.refresh_dependency(auto_open=False) self._check_args() def recheck_advisories(self): """Re-run the read-only sidecar / permission advisories (#101 hot-reload). Public entry point for the file-watcher and focus-in fallback: it only recomputes the warning labels from the current form + the filesystem — it never touches the form's field values, so it is safe to call regardless of unsaved edits (it can't clobber them). Delegates to the same recompute the editor runs on every field change. """ self._check_args() # --- args sanity check ------------------------------------------------ # def _current_arg_lines(self) -> list[str]: return [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""] def _check_args(self): """Warn (and offer a fix) when a line looks like several args in one.""" if self.type.currentIndex() != 0: # stdio page only self.args_warn.hide() self.args_fix_btn.hide() self.secret_warn.hide() self.removed_flag_warn.hide() self.removed_flag_fix_btn.hide() self.sidecar_warn.hide() self.sidecar_edit_btn.hide() self.perm_warn.hide() self.perm_fix_btn.hide() return _, notes = core.split_suspicious_args(self._current_arg_lines()) if notes: self.args_warn.setText("⚠ " + "\n".join(notes)) self.args_warn.show() self.args_fix_btn.show() else: self.args_warn.hide() self.args_fix_btn.hide() warning = core.args_secret_warning({"args": self._current_arg_lines()}) if warning: self.secret_warn.setText("⚠ " + warning) self.secret_warn.show() else: self.secret_warn.hide() # Flags a package removed across a major version (e.g. ssh-mcp v2's # --password). Needs command + env too, so build from the live form. stdio = { "command": self.command.text().strip(), "args": self._current_arg_lines(), "env": self.env.dump(), } rf_warnings = core.removed_flag_warnings(stdio) if rf_warnings: self.removed_flag_warn.setText("⚠ " + "\n".join(rf_warnings)) self.removed_flag_warn.show() # Only offer the button when something is actually auto-migratable. migrated, _ = core.migrate_removed_flags(stdio) self.removed_flag_fix_btn.setVisible(migrated is not stdio) else: self.removed_flag_warn.hide() self.removed_flag_fix_btn.hide() # Sidecar precedence / wrong-path / credential-scoping (#91). Uses the # real platform + environment + filesystem so it reflects this machine. sc_warnings = core.sidecar_warnings(stdio) if sc_warnings: self.sidecar_warn.setText("⚠ " + "\n\n".join(sc_warnings)) self.sidecar_warn.show() else: self.sidecar_warn.hide() # Offer the in-app editor (#102) whenever this server has a resolvable # sidecar — even before the file exists, so it can be created from BCC. self.sidecar_edit_btn.setVisible(core.sidecar_status(stdio) is not None) # Sidecar filesystem permissions (#93). Real platform/fs; no-op on Windows. perm_warnings = core.sidecar_permission_warnings(stdio) if perm_warnings: self.perm_warn.setText("⚠ " + "\n".join(perm_warnings)) self.perm_warn.show() self.perm_fix_btn.show() else: self.perm_warn.hide() self.perm_fix_btn.hide() def _fix_args(self): if self._before_change: self._before_change() fixed, _ = core.split_suspicious_args(self._current_arg_lines()) self.args.setPlainText("\n".join(fixed)) # triggers _emit -> recheck def _fix_removed_flags(self): if self._before_change: self._before_change() stdio = { "command": self.command.text().strip(), "args": self._current_arg_lines(), "env": self.env.dump(), } migrated, _ = core.migrate_removed_flags(stdio) # Reload env first, then args; setting args text triggers _emit -> recheck. self.env.load(migrated.get("env", {})) self.args.setPlainText("\n".join(migrated.get("args", []))) def _fix_permissions(self): """chmod the sidecar config to 0600 / its dir to 0700 (#93).""" stdio = { "command": self.command.text().strip(), "args": self._current_arg_lines(), "env": self.env.dump(), } target = core.sidecar_permission_fix_target(stdio) if target is None: return changed, note = core.fix_permissions(target) if not changed: # Surface the failure in-place rather than silently doing nothing. self.perm_warn.setText("⚠ " + (note or "could not change permissions")) return self._check_args() # re-check; the warning clears when perms are now tight def _edit_sidecar(self): """Open the in-app sidecar editor for this server's external config (#102).""" stdio = { "command": self.command.text().strip(), "args": self._current_arg_lines(), "env": self.env.dump(), } status = core.sidecar_status(stdio) spec = core.resolve_server_spec(stdio) if status is None or spec is None: return path = status["path"] try: text = path.read_text(encoding="utf-8") if status["exists"] else "" except OSError as e: QMessageBox.warning(self.window(), "Can't open config", f"{path}\n\n{e}") return dlg = SidecarEditorDialog(self.window(), spec.package, path, spec.schema, text) if dlg.exec() and dlg.saved: # Re-run detection through the same read-only path the #101 watcher # uses, so "args inert" / permission advisories update live. self._check_args() bnote = f" · backup: {dlg.backup_path.name}" if dlg.backup_path else " · (new file)" QMessageBox.information( self.window(), "Config saved", f"Saved {path}{bnote}\nThe file was tightened to 0600.", ) # --- dependency ------------------------------------------------------ # def refresh_dependency(self, auto_open=False): if not self.isEnabled(): self._set_dep({"status": "unknown", "label": "—"}) self.diag_text.clear() for wdg in self.ver_row_widgets: wdg.hide() return data = self.dump_data() res = core.check_dependency(data) self._set_dep(res) self.fix_btn.setVisible(res["status"] == "warn") is_remote = self.type.currentIndex() == 1 self.test_btn.setVisible(is_remote) self.spawn_btn.setVisible(not is_remote and res["status"] in ("ok", "warn")) problem = res["status"] in ("missing", "warn", "unknown") if auto_open and problem and not self.details_btn.isChecked(): self.details_btn.setChecked(True) # opens panel (fills text via _toggle_diag) if self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) self._refresh_version_badge(data) def _refresh_version_badge(self, data: dict): """Show the resolved version / pin state for an npx-style server (#92).""" st = core.version_status(data) if self.type.currentIndex() == 0 else None if st is None: for wdg in self.ver_row_widgets: wdg.hide() return for wdg in self.ver_row_widgets: wdg.show() resolved = st["resolved_version"] or "unknown" if st["drift"]: glyph, color, text = "●", WARN, f"{st['package']} {st['drift']}" elif st["unpinned"]: glyph, color = "●", WARN text = f"{st['package']} · resolved {resolved} · unpinned (resolves latest each launch)" else: glyph, color = "●", GOOD text = f"{st['package']} · pinned {st['pinned_version']}" self.ver_dot.setText(glyph) self.ver_dot.setStyleSheet(f"color: {color}; font-size: 14px;") self.ver_label.setText(text) self.ver_label.setStyleSheet(f"color: {color};") # Offer the pin only when unpinned AND we know what to pin to. self.pin_btn.setVisible(st["can_pin"]) if st["can_pin"]: self.pin_btn.setText(f"Pin to {resolved}") self._version_resolved = st["resolved_version"] def _pin_version(self): if self._before_change: self._before_change() resolved = getattr(self, "_version_resolved", None) if not resolved: return new_data, note = core.pin_spec_transform(self.dump_data(), resolved) if not note: return self._loading = True self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or []))) self._loading = False self._emit() # writes back to the model and re-checks (badge now "pinned") def _set_dep(self, res: dict): status = res.get("status", "unknown") self.dep_dot.setText(STATUS_GLYPH.get(status, "○")) self.dep_dot.setStyleSheet(f"color: {STATUS_COLORS.get(status, MUTED)}; font-size: 14px;") self.dep_label.setText(res.get("label", "—")) color = STATUS_COLORS.get(status, MUTED) if status in ("missing", "warn") else MUTED self.dep_label.setStyleSheet(f"color: {color};") def _full_diag_text(self) -> str: text = core.diagnostics_text(self.current_name(), self.dump_data()) if self._last_spawn and self._last_spawn.get("stderr", "").strip(): text += "\n\n── stderr from last Test launch ──\n" + self._last_spawn["stderr"].strip() return text def _toggle_diag(self, on): self.diag_card.setVisible(on) self.details_btn.setText("Details ▾" if on else "Details ▸") if on and self.isEnabled(): self.diag_text.setPlainText(self._full_diag_text()) def _recheck(self): core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime self.refresh_dependency(auto_open=True) def _copy_diag(self): QGuiApplication.clipboard().setText(self.diag_text.toPlainText()) self.copy_diag_btn.setText("Copied ✓") QTimer.singleShot(1200, lambda: self.copy_diag_btn.setText("Copy diagnostics")) def _pin_path(self): new_data, note = core.pin_command_path(self.dump_data()) if not note: return self._loading = True self.command.setText(str(new_data.get("command", ""))) self.args.setPlainText("\n".join(str(a) for a in (new_data.get("args") or []))) self._loading = False self._emit() # writes back to the model and re-checks (should now be ok) def _test_remote(self): url = self.url.text().strip() if not url: return self.test_btn.setEnabled(False) self.test_btn.setText("Testing...") self.dep_dot.setText("◆") self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;") self.dep_label.setText("testing reachability...") self.dep_label.setStyleSheet(f"color: {MUTED};") self._tester = ConnTester(url, timeout=6.0) self._tester.done.connect(self._on_test_done) self._tester.start() def _on_test_done(self, ok, detail): self.test_btn.setEnabled(True) self.test_btn.setText("Test connection") color = GOOD if ok else BAD self.dep_dot.setText("●") self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;") self.dep_label.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}") self.dep_label.setStyleSheet(f"color: {color};") def _test_spawn(self): self.spawn_btn.setEnabled(False) self.spawn_btn.setText("Launching…") self.dep_dot.setText("◆") self.dep_dot.setStyleSheet(f"color: {MUTED}; font-size: 14px;") self.dep_label.setText("spawning server…") self.dep_label.setStyleSheet(f"color: {MUTED};") self._spawner = SpawnTester(self.dump_data(), timeout=3.0) self._spawner.done.connect(self._on_spawn_done) self._spawner.start() def _on_spawn_done(self, result: dict): self.spawn_btn.setEnabled(True) self.spawn_btn.setText("Test launch") self._last_spawn = result outcome = result.get("outcome", "") detail = result.get("detail", "") if outcome == "ok": color = GOOD label = f"started · {detail}" elif outcome == "exited": color = WARN label = f"exited cleanly · {detail}" else: color = BAD label = f"{outcome} · {detail}" self.dep_dot.setText("●") self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;") self.dep_label.setText(label) self.dep_label.setStyleSheet(f"color: {color};") # Auto-open the details panel on non-ok outcomes so stderr is visible. if outcome != "ok" and not self.details_btn.isChecked(): self.details_btn.setChecked(True) # triggers _toggle_diag → fills diag_text elif self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) def _view_logs(self): name = self.current_name() if not name: return dlg = LogViewerDialog(self.window(), name) dlg.exec() # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that # model is visible (a long path on its own line reads as "arg 2", not as a # wrapped continuation of arg 1). # --------------------------------------------------------------------------- # class _ArgsGutter(QWidget): def __init__(self, editor: ArgsEdit): super().__init__(editor) self._editor = editor def sizeHint(self): return QSize(self._editor.gutter_width(), 0) def paintEvent(self, event): self._editor.gutter_paint(event) class ArgsEdit(QPlainTextEdit): def __init__(self): super().__init__() self.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) self._gutter = _ArgsGutter(self) self.blockCountChanged.connect(self._update_gutter_width) self.updateRequest.connect(self._on_update_request) self._update_gutter_width() # Wired by ServerEditor: gate on the loaded client, and the two move # actions (which the editor performs, since moving an arg into env # touches both the args and the env table). Indices are into the # non-blank arg list, matching dump_data()'s args. self.profile_provider = None self.on_move_to_ref = None self.on_move_to_env = None def contextMenuEvent(self, event): menu = self.createStandardContextMenu() # keep cut/copy/paste lines = self.toPlainText().splitlines() block = self.cursorForPosition(event.pos()).blockNumber() if 0 <= block < len(lines) and lines[block].strip(): # This editor is one arg per line; map the clicked block to its # index among the non-blank args the model actually sees. cleaned = [ln for ln in lines if ln.strip() != ""] idx = sum(1 for ln in lines[:block] if ln.strip() != "") if idx in set(core.secret_arg_indices(cleaned)): profile = self.profile_provider() if self.profile_provider else None expands = profile is None or core.client_expands_env_refs(profile) first = menu.actions()[0] if menu.actions() else None # Reference (secret leaves the file) -- needs an expanding client. ref_act = QAction("Replace with a ${VAR} reference (out of file)…", self) if expands and self.on_move_to_ref: ref_act.triggered.connect(lambda: self.on_move_to_ref(idx)) else: ref_act.setEnabled(False) ref_act.setText( "Replace with ${VAR} reference — unavailable for Claude Desktop" ) ref_act.setToolTip( "Claude Desktop doesn't expand ${VAR}, so a reference would " "reach the server as literal text." ) # Move into the env block (kept in file) -- works on any client. env_act = QAction("Move into Environment variables (kept in this config)…", self) if self.on_move_to_env: env_act.triggered.connect(lambda: self.on_move_to_env(idx)) menu.insertAction(first, ref_act) menu.insertAction(first, env_act) if first is not None: menu.insertSeparator(first) menu.exec(event.globalPos()) def gutter_width(self) -> int: digits = max(1, len(str(self.blockCount()))) return 14 + self.fontMetrics().horizontalAdvance("9") * digits def _update_gutter_width(self, *_): self.setViewportMargins(self.gutter_width(), 0, 0, 0) def _on_update_request(self, rect, dy): if dy: self._gutter.scroll(0, dy) else: self._gutter.update(0, rect.y(), self._gutter.width(), rect.height()) if rect.contains(self.viewport().rect()): self._update_gutter_width() def resizeEvent(self, e): super().resizeEvent(e) cr = self.contentsRect() self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.gutter_width(), cr.height())) def gutter_paint(self, event): painter = QPainter(self._gutter) painter.setPen(QColor(MUTED)) fm = self.fontMetrics() block = self.firstVisibleBlock() n = block.blockNumber() top = round(self.blockBoundingGeometry(block).translated(self.contentOffset()).top()) bottom = top + round(self.blockBoundingRect(block).height()) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.drawText( 0, top, self._gutter.width() - 8, fm.height(), Qt.AlignmentFlag.AlignRight, str(n + 1), ) block = block.next() top = bottom bottom = top + round(self.blockBoundingRect(block).height()) n += 1 # --------------------------------------------------------------------------- # # Config-repair dialog: shown when a config file on disk fails strict JSON # parsing but the repair pipeline can salvage it. Lists what's wrong, what # would change, and lets the user decide. Nothing touches disk until Save. # --------------------------------------------------------------------------- # class RepairDialog(QDialog): def __init__(self, parent, path, error: str, notes: list[str], preview: str): super().__init__(parent) self.setWindowTitle("Config file needs repair") self.resize(620, 520) v = QVBoxLayout(self) v.setSpacing(10) intro = QLabel(f"{path}
isn't valid JSON: {error}") intro.setWordWrap(True) v.addWidget(intro) fixes_hdr = QLabel("BCC can repair it automatically. Fixes to apply:") v.addWidget(fixes_hdr) fixes = QLabel("\n".join(f" • {n}" for n in notes) or " • reformat as valid JSON") fixes.setObjectName("muted") fixes.setWordWrap(True) v.addWidget(fixes) prev_hdr = QLabel("The file would become:") v.addWidget(prev_hdr) box = QPlainTextEdit() box.setObjectName("diag") box.setReadOnly(True) box.setPlainText(preview) v.addWidget(box, 1) note = QLabel( "Nothing is written yet — choosing Repair loads the fixed config into the " "editor. The file is only rewritten when you press Save, and the broken " "original is backed up first." ) note.setObjectName("muted") note.setWordWrap(True) v.addWidget(note) btns = QDialogButtonBox() ok = btns.addButton("Repair && load", QDialogButtonBox.ButtonRole.AcceptRole) ok.setObjectName("primary") btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.accepted.connect(self.accept) btns.rejected.connect(self.reject) v.addWidget(btns) # --------------------------------------------------------------------------- # # Backup restore dialog: lists available backups, shows a diff of what will # change, and confirms before writing. Only servers are restored — all other # keys in the current config are preserved verbatim. # --------------------------------------------------------------------------- # class RestoreDialog(QDialog): def __init__(self, parent, profile: core.Profile, full_config: dict): super().__init__(parent) self.setWindowTitle(f"Restore from backup — {profile.label}") self.resize(700, 480) self._profile = profile self._full_config = full_config v = QVBoxLayout(self) v.setSpacing(8) intro = QLabel( "Select a backup to preview the change. Only mcpServers and " "_disabledMcpServers are restored — all other keys in your config are preserved." ) intro.setWordWrap(True) v.addWidget(intro) splitter = QSplitter(Qt.Orientation.Horizontal) # --- left: backup list --- left = QWidget() lv = QVBoxLayout(left) lv.setContentsMargins(0, 0, 0, 0) lv.addWidget(QLabel("Backups (newest first):")) self.backup_list = QListWidget() self.backup_list.currentItemChanged.connect(self._on_backup_selected) lv.addWidget(self.backup_list, 1) splitter.addWidget(left) # --- right: diff preview --- right = QWidget() rv = QVBoxLayout(right) rv.setContentsMargins(0, 0, 0, 0) rv.addWidget(QLabel("Preview (what will change):")) self.diff_view = QPlainTextEdit() self.diff_view.setObjectName("diag") self.diff_view.setReadOnly(True) self.diff_view.setPlaceholderText("Select a backup on the left to see the diff.") rv.addWidget(self.diff_view, 1) splitter.addWidget(right) splitter.setSizes([220, 460]) v.addWidget(splitter, 1) self.btns = QDialogButtonBox() self.restore_btn = self.btns.addButton("Restore", QDialogButtonBox.ButtonRole.AcceptRole) self.restore_btn.setObjectName("primary") self.restore_btn.setEnabled(False) self.btns.addButton(QDialogButtonBox.StandardButton.Cancel) self.btns.accepted.connect(self.accept) self.btns.rejected.connect(self.reject) v.addWidget(self.btns) self._populate() def _populate(self): backups = core.list_backups(self._profile.path) if not backups: self.backup_list.addItem("(no backups found)") return for bp in backups: item = QListWidgetItem(core.backup_label(bp)) item.setData(Qt.ItemDataRole.UserRole, str(bp)) self.backup_list.addItem(item) def _on_backup_selected(self, current: QListWidgetItem | None, _prev): if current is None or current.data(Qt.ItemDataRole.UserRole) is None: self.diff_view.setPlainText("") self.restore_btn.setEnabled(False) return bp = current.data(Qt.ItemDataRole.UserRole) try: diff = core.backup_diff(self._profile.path, bp, self._full_config) except Exception as e: diff = f"(error generating diff: {e})" self.diff_view.setPlainText(diff) self.restore_btn.setEnabled(True) def selected_backup(self) -> str | None: item = self.backup_list.currentItem() if item is None: return None return item.data(Qt.ItemDataRole.UserRole) # --------------------------------------------------------------------------- # # Stale-file conflict dialog # Shown when the config file was modified on disk between load and save. # --------------------------------------------------------------------------- # class StaleDialog(QDialog): MERGE = 0 OVERWRITE = 1 def __init__( self, parent, profile_path: str, changed_keys: list[str], server_diff: str, ): super().__init__(parent) self.setWindowTitle("Config changed on disk") self.resize(680, 420) self._choice = self.MERGE v = QVBoxLayout(self) v.setSpacing(8) intro = QLabel(f"{profile_path} was modified on disk since you loaded it.") intro.setWordWrap(True) v.addWidget(intro) if changed_keys: kl = QLabel("Changed keys: " + ", ".join(f"{k}" for k in changed_keys)) kl.setObjectName("muted") kl.setWordWrap(True) v.addWidget(kl) if server_diff: v.addWidget(QLabel("Server section changes (on disk vs loaded):")) dv = QPlainTextEdit() dv.setObjectName("diag") dv.setReadOnly(True) dv.setPlainText(server_diff) v.addWidget(dv, 1) else: note = QLabel("(Server sections unchanged — the external edit is in other keys.)") note.setObjectName("muted") v.addWidget(note) desc = QLabel( "Merge & save applies your server edits on top of the current file " "on disk — both sets of changes are kept. " "Overwrite saves your in-memory state, discarding external changes." ) desc.setObjectName("muted") desc.setWordWrap(True) v.addWidget(desc) btns = QDialogButtonBox() merge_btn = btns.addButton("Merge && save", QDialogButtonBox.ButtonRole.AcceptRole) merge_btn.setObjectName("primary") overwrite_btn = btns.addButton( "Overwrite anyway", QDialogButtonBox.ButtonRole.DestructiveRole ) btns.addButton(QDialogButtonBox.StandardButton.Cancel) merge_btn.clicked.connect(lambda: self._choose(self.MERGE)) overwrite_btn.clicked.connect(lambda: self._choose(self.OVERWRITE)) btns.rejected.connect(self.reject) v.addWidget(btns) def _choose(self, choice: int) -> None: self._choice = choice self.accept() def choice(self) -> int: return self._choice # --------------------------------------------------------------------------- # # Paste-JSON dialog # --------------------------------------------------------------------------- # class PasteDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Paste MCP JSON") self.resize(560, 420) self.result_servers: dict[str, dict] | None = None v = QVBoxLayout(self) info = QLabel( "Paste a config snippet from any MCP doc — it doesn't have to be " "perfect JSON. Markdown fences, comments, trailing commas, smart " "quotes, and missing braces are fixed automatically." ) info.setObjectName("muted") info.setWordWrap(True) v.addWidget(info) self.box = QPlainTextEdit() self.box.setPlaceholderText( '{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}' ) v.addWidget(self.box, 1) self.err = QLabel("") self.err.setWordWrap(True) v.addWidget(self.err) btns = QDialogButtonBox() self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole) self.parse_btn.setObjectName("primary") self.parse_btn.setEnabled(False) btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.accepted.connect(self._try_parse) btns.rejected.connect(self.reject) v.addWidget(btns) # Live parse-as-you-type, debounced so we don't churn on every keystroke. self._debounce = QTimer(self) self._debounce.setSingleShot(True) self._debounce.setInterval(250) self._debounce.timeout.connect(self._preview) self.box.textChanged.connect(self._debounce.start) def _preview(self): text = self.box.toPlainText() if not text.strip(): self.err.setText("") self.parse_btn.setEnabled(False) return try: servers, notes = core.parse_pasted_json_verbose(text) except Exception as e: self.err.setStyleSheet(f"color: {BAD};") self.err.setText(str(e)) self.parse_btn.setEnabled(False) return names = ", ".join( f"{n} ({core.ServerEntry(n, d).kind})" for n, d in list(servers.items())[:6] ) if len(servers) > 6: names += f", +{len(servers) - 6} more" msg = f"✓ Will add: {names}" if notes: msg += "\nAuto-repaired: " + "; ".join(notes) self.err.setStyleSheet(f"color: {GOOD};") self.err.setText(msg) self.parse_btn.setEnabled(True) def _try_parse(self): try: self.result_servers = core.parse_pasted_json(self.box.toPlainText()) self.accept() except Exception as e: self.err.setStyleSheet(f"color: {BAD};") self.err.setText(str(e)) # --------------------------------------------------------------------------- # # Log viewer dialog (issue #6): a read-only, auto-tailing view of a single # server's MCP log file. Polls on a QTimer instead of watching the filesystem # so it works the same on every platform; never writes to the log. # --------------------------------------------------------------------------- # class LogViewerDialog(QDialog): POLL_MS = 1500 MAX_TAIL_BYTES = 300_000 def __init__(self, parent, server_name: str): super().__init__(parent) self._name = server_name self._last_path: Path | None = None self._last_size: int | None = None self.setWindowTitle(f"Logs — {server_name}") self.resize(760, 520) v = QVBoxLayout(self) v.setSpacing(8) self.path_label = QLabel("") self.path_label.setObjectName("muted") self.path_label.setWordWrap(True) v.addWidget(self.path_label) self.view = QPlainTextEdit() self.view.setObjectName("diag") self.view.setReadOnly(True) self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) v.addWidget(self.view, 1) row = QHBoxLayout() self.status_label = QLabel("") self.status_label.setObjectName("muted") row.addWidget(self.status_label, 1) refresh_btn = QPushButton("Refresh now") refresh_btn.clicked.connect(self._poll) row.addWidget(refresh_btn) close_btn = QPushButton("Close") close_btn.setObjectName("primary") close_btn.clicked.connect(self.accept) row.addWidget(close_btn) v.addLayout(row) self._timer = QTimer(self) self._timer.setInterval(self.POLL_MS) self._timer.timeout.connect(self._poll) self._timer.start() self._poll() def _poll(self): path = core.server_log_path(self._name) if path is None: self._last_path = None self._last_size = None self.path_label.setText(f"No log file for '{self._name}' yet.") self.view.setPlaceholderText( "No log yet — this fills in once the server has run at least once " "and produced output." ) self.view.clear() self.status_label.setText("Waiting…") return self.path_label.setText(str(path)) try: size = path.stat().st_size except OSError: self.status_label.setText("Log file disappeared.") return # Nothing changed since the last poll -> skip the re-read/re-render. if path == self._last_path and size == self._last_size: return was_at_bottom = self._is_scrolled_to_bottom() text, truncated = self._read_tail(path) self.view.setPlainText(text) self.status_label.setText( f"Showing last {self.MAX_TAIL_BYTES // 1000} KB of the log." if truncated else "" ) if was_at_bottom: self._scroll_to_bottom() self._last_path = path self._last_size = size def _read_tail(self, path: Path) -> tuple[str, bool]: try: size = path.stat().st_size with open(path, "rb") as f: truncated = size > self.MAX_TAIL_BYTES if truncated: f.seek(size - self.MAX_TAIL_BYTES) data = f.read() except OSError as e: return f"(could not read log: {e})", False text = data.decode("utf-8", errors="replace") if truncated: nl = text.find("\n") if nl != -1: text = text[nl + 1 :] text = "… (earlier lines truncated) …\n" + text return text, truncated def _is_scrolled_to_bottom(self) -> bool: sb = self.view.verticalScrollBar() return sb.value() >= sb.maximum() - 4 def _scroll_to_bottom(self): sb = self.view.verticalScrollBar() sb.setValue(sb.maximum()) def closeEvent(self, event): self._timer.stop() super().closeEvent(event) # --------------------------------------------------------------------------- # # Bundled assets (icons) — resolves both a normal source run and a frozen # PyInstaller build (onefile extracts assets under sys._MEIPASS). # --------------------------------------------------------------------------- # def _asset_dir() -> Path: base = getattr(sys, "_MEIPASS", None) return Path(base) if base else Path(__file__).resolve().parent def _app_icon() -> QIcon: """Multi-resolution app/window icon from the bundled PNGs (falls back to the .ico). Returns a null QIcon if no asset is found.""" icon = QIcon() base = _asset_dir() / "icons" / "twin-gears" / "rounded" for size in (16, 32, 48, 64, 128, 256, 512): f = base / f"icon-{size}.png" if f.is_file(): icon.addFile(str(f)) if icon.isNull(): ico = _asset_dir() / "icons" / "app.ico" if ico.is_file(): icon.addFile(str(ico)) return icon # --------------------------------------------------------------------------- # # About dialog # --------------------------------------------------------------------------- # class AboutDialog(QDialog): """ App info + a manual "Check for updates" action. Shows nothing sensitive: app name, icon, version (from core.__version__), and links opened in the system browser via QDesktopServices.openUrl — never navigated to in-app. The update check itself only ever reads release metadata (see UpdateCheckWorker); it never downloads or replaces the running binary. """ def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("About Better Claude Config") self.setFixedWidth(440) self._worker: UpdateCheckWorker | None = None self._release_url: str | None = None icon_path = _asset_dir() / "icons" / "twin-gears" / "rounded" / "icon-128.png" if icon_path.is_file(): self.setWindowIcon(QIcon(str(icon_path))) v = QVBoxLayout(self) v.setSpacing(10) head = QHBoxLayout() head.setSpacing(12) icon_lbl = QLabel() if icon_path.is_file(): icon_lbl.setPixmap( QPixmap(str(icon_path)).scaled( 56, 56, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation, ) ) head.addWidget(icon_lbl) title_box = QVBoxLayout() title_box.setSpacing(2) name_lbl = QLabel("Better Claude Config") name_lbl.setObjectName("h1") title_box.addWidget(name_lbl) ver_lbl = QLabel(f"Version {core.__version__}") ver_lbl.setObjectName("muted") title_box.addWidget(ver_lbl) head.addLayout(title_box, 1) v.addLayout(head) desc = QLabel( "A cross-platform GUI for editing the mcpServers block of Claude " "Desktop and Claude Code configs." ) desc.setObjectName("muted") desc.setWordWrap(True) v.addWidget(desc) if sys.platform == "darwin": note = QLabel( "This build isn't notarized by Apple. If macOS blocks it on first " "launch, right-click the app ▸ Open, or allow it under System " "Settings ▸ Privacy & Security." ) note.setObjectName("muted") note.setWordWrap(True) v.addWidget(note) links = QHBoxLayout() for text, url in ( ("Repository", core.REPO_URL), ("Issues", core.ISSUES_URL), ("MIT License", core.LICENSE_URL), ): btn = QPushButton(text) btn.setFlat(True) btn.setCursor(Qt.CursorShape.PointingHandCursor) btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) links.addWidget(btn) links.addStretch() v.addLayout(links) # --- update check ----------------------------------------------- # upd_row = QHBoxLayout() self.check_btn = QPushButton("Check for updates") self.check_btn.clicked.connect(self._check_for_updates) upd_row.addWidget(self.check_btn) self.update_status = QLabel("") self.update_status.setObjectName("muted") self.update_status.setWordWrap(True) upd_row.addWidget(self.update_status, 1) v.addLayout(upd_row) self.release_btn = QPushButton("Open releases page") self.release_btn.setCursor(Qt.CursorShape.PointingHandCursor) self.release_btn.clicked.connect(self._open_release_page) self.release_btn.hide() v.addWidget(self.release_btn) self.auto_check_box = QCheckBox("Automatically check for updates on startup") st = QSettings("BCC", "BetterClaudeConfig") self.auto_check_box.setChecked(bool(st.value("update/autoCheck", True, type=bool))) self.auto_check_box.toggled.connect(self._toggle_auto_check) v.addWidget(self.auto_check_box) btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) btns.rejected.connect(self.reject) btns.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.accept) v.addWidget(btns) def _toggle_auto_check(self, on: bool): QSettings("BCC", "BetterClaudeConfig").setValue("update/autoCheck", on) def _check_for_updates(self): self.check_btn.setEnabled(False) self.release_btn.hide() self.update_status.setStyleSheet(f"color: {MUTED};") self.update_status.setText("Checking…") self._worker = UpdateCheckWorker() self._worker.done.connect(self._on_check_done) self._worker.start() def _on_check_done(self, release: dict | None): self.check_btn.setEnabled(True) self._worker = None if not release: self.update_status.setStyleSheet(f"color: {MUTED};") self.update_status.setText("Couldn't check for updates (offline?).") return if core.is_newer_version(core.__version__, release["version"]): self.update_status.setStyleSheet(f"color: {ACCENT};") self.update_status.setText(f"{release['version']} available.") self._release_url = release.get("url") or core.RELEASES_URL self.release_btn.show() else: self.update_status.setStyleSheet(f"color: {GOOD};") self.update_status.setText("You're up to date.") def _open_release_page(self): QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) class NoticeBanner(QFrame): """A persistent, dismissible notice with an optional action button. The status bar is the wrong home for anything the user needs to act on -- 21 call sites rewrite it, so a message posted there is gone by the next click. That wiped the MSIX warning (#35) and then the update notice (#78). This is the shared mechanism so it doesn't happen a third time. """ def __init__(self, parent=None): super().__init__(parent) self.setObjectName("noticeBanner") row = QHBoxLayout(self) row.setContentsMargins(10, 8, 8, 8) row.setSpacing(8) self._label = QLabel("") self._label.setObjectName("noticeText") self._label.setWordWrap(True) row.addWidget(self._label, 1) self._action_btn = QPushButton("") self._action_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._action_btn.hide() row.addWidget(self._action_btn) self._close_btn = QPushButton("\u2715") self._close_btn.setObjectName("noticeClose") self._close_btn.setCursor(Qt.CursorShape.PointingHandCursor) self._close_btn.setFixedWidth(26) self._close_btn.setToolTip("Dismiss") self._close_btn.clicked.connect(self.hide) row.addWidget(self._close_btn) self.hide() def show_notice(self, text: str, action_label: str = "", on_action=None): self._label.setText(text) self._label.setToolTip(text) # Reconnect cleanly: a banner reused for a second notice would # otherwise fire the previous notice's action too. with contextlib.suppress(RuntimeError, TypeError): self._action_btn.clicked.disconnect() if action_label and on_action is not None: self._action_btn.setText(action_label) self._action_btn.clicked.connect(lambda _=False: on_action()) self._action_btn.show() else: self._action_btn.hide() self.show() _UNSET_CHOICE = "— (unset) —" # sentinel entry for an enum pick-list class SidecarEditorDialog(QDialog): """Edit an external sidecar config (ssh-mcp's config.toml) from inside BCC (#102). A minimal, reviewable first pass: pick-lists for the schema enum fields and a numeric spinner for the port, plus a raw-text editor that is the full, always-available fallback. On Save the changed managed fields are applied surgically on top of the raw text (so comments/unknown keys survive), the managed values are validated against the ServerSpec schema, and the file is written through core.write_sidecar (atomic + timestamped backup + chmod 0600). All decision logic lives in bcc_core; this class is wiring. """ def __init__(self, parent, package: str, path: Path, schema: dict, initial_text: str): super().__init__(parent) self.setWindowTitle(f"Edit {package} config") self.resize(620, 620) self._path = Path(path) self._schema = schema or {} self.backup_path: Path | None = None # set on a successful save self.saved = False v = QVBoxLayout(self) info = QLabel( f"Editing {path}
" "Pick-lists cover the known settings; the raw editor below is the full " "file. Saving writes atomically, keeps a timestamped backup, and tightens " "the file to 0600." ) info.setObjectName("muted") info.setWordWrap(True) info.setTextFormat(Qt.TextFormat.RichText) v.addWidget(info) # Which section the pick-lists target. ssh-mcp configs are section-based # ([server] / per-profile); default to the first section, else top-level. sec_row = QHBoxLayout() sec_row.addWidget(QLabel("Section:")) self.section_combo = QComboBox() sections = core.toml_sections(initial_text) or [""] for s in sections: self.section_combo.addItem("(top level)" if s == "" else s, s) # Prefer a section literally called "server" if present. if "server" in sections: self.section_combo.setCurrentIndex(sections.index("server")) self.section_combo.currentIndexChanged.connect(self._reload_fields_from_raw) sec_row.addWidget(self.section_combo, 1) v.addLayout(sec_row) # Managed schema fields. self._fields_box = QGroupBox("Known settings") self._form = QFormLayout(self._fields_box) self._enum_widgets: dict[str, QComboBox] = {} self._port_widget: QSpinBox | None = None self._port_present = QCheckBox("set") # gate for whether port is written self._build_fields() v.addWidget(self._fields_box) v.addWidget(QLabel("Raw TOML (full file — edit anything here):")) self.raw = QPlainTextEdit() self.raw.setPlainText(initial_text) mono = self.raw.font() mono.setFamily("Menlo, Consolas, monospace") self.raw.setFont(mono) v.addWidget(self.raw, 1) self.err = QLabel("") self.err.setStyleSheet(f"color: {BAD};") self.err.setWordWrap(True) self.err.hide() v.addWidget(self.err) btns = QDialogButtonBox() self.save_btn = btns.addButton("Save", QDialogButtonBox.ButtonRole.AcceptRole) self.save_btn.setObjectName("primary") btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.accepted.connect(self._save) btns.rejected.connect(self.reject) v.addWidget(btns) self._reload_fields_from_raw() def _current_section(self) -> str | None: s = self.section_combo.currentData() return None if s == "" else s def _build_fields(self): """Create a widget per schema key (enums -> combo, port range -> spin).""" for key, rule in self._schema.items(): if isinstance(rule, list): # enum combo = QComboBox() combo.addItem(_UNSET_CHOICE, None) for opt in rule: combo.addItem(str(opt), opt) self._enum_widgets[key] = combo self._form.addRow(f"{key}:", combo) elif isinstance(rule, dict) and "min" in rule and "max" in rule: # numeric range row = QHBoxLayout() spin = QSpinBox() spin.setRange(int(rule["min"]), int(rule["max"])) spin.setEnabled(False) self._port_present.toggled.connect(spin.setEnabled) row.addWidget(self._port_present) row.addWidget(spin, 1) holder = QWidget() holder.setLayout(row) self._port_widget = spin self._form.addRow(f"{key}:", holder) def _reload_fields_from_raw(self): """Populate the pick-lists from the raw text for the chosen section, and remember the loaded state so Save only applies fields the user changed.""" values = core.read_toml_section(self.raw.toPlainText(), self._current_section()) self._loaded: dict = {} for key, combo in self._enum_widgets.items(): val = values.get(key) idx = combo.findData(val) if val is not None else 0 combo.setCurrentIndex(idx if idx >= 0 else 0) # An out-of-enum current value can't be shown; leave it at (unset) but # DON'T record it as loaded so we never silently overwrite it on save. self._loaded[key] = combo.currentData() if self._port_widget is not None: pv = values.get("port") has = isinstance(pv, int) and not isinstance(pv, bool) self._port_present.setChecked(has) if has: self._port_widget.setValue(pv) self._loaded["port"] = pv if has else None def _managed_updates(self) -> dict: """The managed keys the user actually changed -> new value (None = delete). Only changed fields are applied, so untouched keys (including any the pick-list can't represent) are left exactly as the raw text has them. """ updates: dict = {} for key, combo in self._enum_widgets.items(): cur = combo.currentData() if cur != self._loaded.get(key): updates[key] = cur # None here means "delete the key" if self._port_widget is not None: cur = self._port_widget.value() if self._port_present.isChecked() else None if cur != self._loaded.get("port"): updates["port"] = cur return updates def _save(self): section = self._current_section() updates = self._managed_updates() # Validate only the managed values the user is actually setting (a None = # delete needs no enum check). to_check = {k: v for k, v in updates.items() if v is not None} problems = core.validate_sidecar_values(to_check, self._schema) if problems: self.err.setText("⚠ " + "\n".join(problems)) self.err.show() return try: new_text = core.update_toml(self.raw.toPlainText(), updates, section=section) self.backup_path = core.write_sidecar(self._path, new_text) except Exception as e: # surface any write/permission failure in-place self.err.setText(f"⚠ Could not save: {e}") self.err.show() return self.saved = True self.accept() # --------------------------------------------------------------------------- # # Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS # waiting for the old instance to exit, so it must run off the UI thread. # --------------------------------------------------------------------------- # class RestartWorker(QThread): done = Signal(object) # core.RestartResult def run(self): self.done.emit(core.restart_claude_desktop()) # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Better Claude Config") self.resize(940, 640) self.setAcceptDrops(True) self._build_menu_bar() self.profiles: list[core.Profile] = [] self.full_config: dict = {} self.servers: list[core.ServerEntry] = [] self.current_profile: core.Profile | None = None self._loaded_stat: core.ConfigStat | None = None self.dirty = False self._suppress_table = False self._suppress_sel = False self._focused_table = None self._row_of_index: dict[int, tuple] = {} self._undo_stack: list[list] = [] # each entry: snapshot of self.servers self._filter_query = "" self._health: dict[str, tuple[str, str]] = {} # server name -> (HealthStatus, summary) self._test_all_queue: list[core.ServerEntry] = [] self._test_all_total = 0 self._test_all_done = 0 self._health_tester: SpawnTester | None = None # Hot-reload (#101): watch the selected server's external sidecar path(s) # and BCC's own loaded config, so changes made outside BCC surface without # a restart. The watcher fires the debounce timer; the timer re-checks. self._fs_watcher = QFileSystemWatcher(self) self._fs_watcher.fileChanged.connect(self._on_fs_signal) self._fs_watcher.directoryChanged.connect(self._on_fs_signal) self._fs_debounce = QTimer(self) self._fs_debounce.setSingleShot(True) self._fs_debounce.setInterval(300) # coalesce a burst of writes self._fs_debounce.timeout.connect(self._recheck_external_state) self._sidecar_fp: tuple | None = None # last observed sidecar state central = QWidget() self.setCentralWidget(central) root = QVBoxLayout(central) root.setContentsMargins(14, 14, 14, 10) root.setSpacing(12) root.addLayout(self._build_topbar()) # Persistent warning banner (MSIX-virtualized config, etc.). Lives in # its own widget because the status bar is rewritten on every action, # which used to wipe the warning before the user could read it. self.warn_banner = QLabel("") self.warn_banner.setObjectName("warnBanner") self.warn_banner.setWordWrap(True) self.warn_banner.hide() root.addWidget(self.warn_banner) # Update availability gets its own persistent banner rather than a # status-line write, which the next UI action overwrites (#78). self.update_banner = NoticeBanner(self) root.addWidget(self.update_banner) # External-change notice (#101): when BCC's own loaded config is edited # outside BCC, surface it here with a one-click Reload rather than # silently overwriting — a live reload must never clobber unsaved edits. self.reload_banner = NoticeBanner(self) root.addWidget(self.reload_banner) # User-draggable divider between the server list and the editor. split = QSplitter(Qt.Orientation.Horizontal) split.setChildrenCollapsible(False) split.setHandleWidth(10) split.addWidget(self._build_left()) self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo) self.editor.set_profile_provider(lambda: self.current_profile) split.addWidget(self.editor) split.setStretchFactor(0, 3) split.setStretchFactor(1, 4) split.setSizes([390, 520]) self.hsplit = split root.addWidget(split, 1) root.addLayout(self._build_actionbar()) status_row = QHBoxLayout() status_row.setContentsMargins(0, 0, 0, 0) self.status = QLabel("Ready.") self.status.setObjectName("statusbar") status_row.addWidget(self.status, 1) self.restart_btn = QPushButton("Restart Claude Desktop") self.restart_btn.setToolTip( "Quit and relaunch Claude Desktop so the saved config takes effect" ) self.restart_btn.clicked.connect(self._restart_claude_desktop) self.restart_btn.hide() status_row.addWidget(self.restart_btn) root.addLayout(status_row) self._restore_layout() self.reload_profiles() self._maybe_auto_check_updates() # --- menu bar ---------------------------------------------------------- # def _build_menu_bar(self): view_menu = self.menuBar().addMenu("&View") theme_menu = view_menu.addMenu("Theme") self._theme_group = QActionGroup(self) self._theme_group.setExclusive(True) current = stored_theme_setting() for setting, label in ( (core.THEME_SYSTEM, "Match system"), (core.THEME_LIGHT, "Light"), (core.THEME_DARK, "Dark"), ): act = QAction(label, self, checkable=True) act.setChecked(setting == current) act.triggered.connect(lambda _checked=False, s=setting: self._set_theme(s)) self._theme_group.addAction(act) theme_menu.addAction(act) help_menu = self.menuBar().addMenu("&Help") # "Check for updates" used to exist only as a button inside the About # dialog, which is not somewhere anyone looks for it (#79). update_action = QAction("Check for updates…", self) # Explicit role: macOS relocates actions it recognises by text, and # some Qt versions treat "update" as application-menu material. Pin it # so the item stays where the menu says it is on every platform. update_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole) update_action.triggered.connect(self.check_for_updates) help_menu.addAction(update_action) help_menu.addSeparator() about_action = QAction("About Better Claude Config…", self) # Qt auto-assigns AboutRole to actions whose text starts with "About", # which moves this into the application menu on macOS. That is the # right home there -- state it explicitly rather than inheriting it by # accident, since the behaviour is invisible from this call site. about_action.setMenuRole(QAction.MenuRole.AboutRole) about_action.triggered.connect(self._show_about) help_menu.addAction(about_action) def _show_update_notice(self, notice: dict): """Surface an available update where it survives the next click.""" url = notice["url"] self.update_banner.show_notice( notice["text"], action_label="Open releases page", on_action=lambda: QDesktopServices.openUrl(QUrl(url)), ) def check_for_updates(self): """Menu-driven check. Unlike the startup check this is never throttled and always reports back -- the user asked, so silence would read as a broken button.""" self.status.setText("Checking for updates…") self._menu_update_worker = UpdateCheckWorker() self._menu_update_worker.done.connect(self._on_menu_update_checked) self._menu_update_worker.start() def _on_menu_update_checked(self, release: dict | None): self._menu_update_worker = None if release is None: self.status.setText("Couldn't check for updates (offline?).") return QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time()) notice = core.update_notice(core.__version__, release) if notice: self._show_update_notice(notice) self.status.setText(f"Update available: {notice['version']}") else: self.update_banner.hide() self.status.setText(f"You're up to date ({core.__version__}).") def _set_theme(self, setting: str): """Persist the theme choice and repaint the running window.""" QSettings("BCC", "BetterClaudeConfig").setValue("ui/theme", setting) app = QApplication.instance() if app is None: # pragma: no cover - only in a headless test harness return app.setStyleSheet(theme_stylesheet_for(app, setting)) # The global stylesheet covers most of the UI, but the inline # setStyleSheet calls (status dots, warning labels, update banner) only # pick up the new palette when their widget next renders -- so re-render # them now rather than leaving dark-on-light text behind. self._repaint_themed_widgets() def _repaint_themed_widgets(self): """Re-run the inline-styled bits after a palette change.""" self.status.setStyleSheet(f"color: {MUTED};") idx = self._current_index() self._refresh_tables(select_index=idx if idx >= 0 else -1) self._update_status(saved=False) def _show_about(self): AboutDialog(self).exec() # --- update check (silent, throttled, off-thread) --------------------- # def _maybe_auto_check_updates(self): st = QSettings("BCC", "BetterClaudeConfig") if not bool(st.value("update/autoCheck", True, type=bool)): return last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0) if (time.time() - last) < 86400: # at most once/day return self._startup_update_worker = UpdateCheckWorker() self._startup_update_worker.done.connect(self._on_startup_update_checked) self._startup_update_worker.start() def _on_startup_update_checked(self, release: dict | None): self._startup_update_worker = None if release is None: return # offline/failed check: don't advance lastCheck, allow retry QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time()) notice = core.update_notice(core.__version__, release) if notice: self._show_update_notice(notice) # --- layout persistence ---------------------------------------------- # def _restore_layout(self): st = QSettings("BCC", "BetterClaudeConfig") if geo := st.value("window/geometry"): self.restoreGeometry(geo) if hs := st.value("window/hsplit"): self.hsplit.restoreState(hs) if vs := st.value("window/vsplit"): self.vsplit.restoreState(vs) def _save_layout(self): st = QSettings("BCC", "BetterClaudeConfig") st.setValue("window/geometry", self.saveGeometry()) st.setValue("window/hsplit", self.hsplit.saveState()) st.setValue("window/vsplit", self.vsplit.saveState()) # --- top bar --------------------------------------------------------- # def _build_topbar(self): bar = QHBoxLayout() lbl = QLabel("Install") lbl.setObjectName("muted") bar.addWidget(lbl) self.profile_combo = QComboBox() self.profile_combo.setMinimumWidth(220) self.profile_combo.currentIndexChanged.connect(self._profile_selected) bar.addWidget(self.profile_combo) add_cfg = QPushButton("Add config...") add_cfg.clicked.connect(self.add_custom_config) bar.addWidget(add_cfg) reload_btn = QPushButton("Reload") reload_btn.clicked.connect(lambda: self.load_profile(self.current_profile, confirm=True)) bar.addWidget(reload_btn) self.restore_btn = QPushButton("Restore…") self.restore_btn.setToolTip("Restore servers from a previous backup") self.restore_btn.clicked.connect(self._restore_from_backup) bar.addWidget(self.restore_btn) bar.addStretch() self.save_btn = QPushButton("Save") self.save_btn.setObjectName("primary") self.save_btn.clicked.connect(self.save) bar.addWidget(self.save_btn) return bar # --- left (server table) -------------------------------------------- # def _make_server_table(self, object_name=None): t = QTableWidget(0, 5) if object_name: t.setObjectName(object_name) t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"]) t.verticalHeader().setVisible(False) t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) t.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) h = t.horizontalHeader() h.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents) t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl)) t.itemChanged.connect(self._table_item_changed) return t def _build_left(self): card = QFrame() card.setObjectName("card") v = QVBoxLayout(card) v.setContentsMargins(12, 12, 12, 12) v.setSpacing(8) head = QLabel("MCP servers") head.setObjectName("h1") v.addWidget(head) search_row = QHBoxLayout() self.search_box = QLineEdit() self.search_box.setPlaceholderText("Search servers by name, command, or url…") self.search_box.setClearButtonEnabled(True) self.search_box.textChanged.connect(self._on_search_changed) search_row.addWidget(self.search_box, 1) self.enable_all_btn = QPushButton("All on") self.enable_all_btn.setToolTip("Enable every server") self.enable_all_btn.clicked.connect(lambda: self._set_all_enabled(True)) search_row.addWidget(self.enable_all_btn) self.disable_all_btn = QPushButton("All off") self.disable_all_btn.setToolTip("Disable every server") self.disable_all_btn.clicked.connect(lambda: self._set_all_enabled(False)) search_row.addWidget(self.disable_all_btn) v.addLayout(search_row) # Named server sets (issue #52): apply a saved Active/Disabled split # in one click. Sets live in the config file under _bccServerSets. sets_row = QHBoxLayout() sets_lbl = QLabel("Set") sets_lbl.setObjectName("muted") sets_row.addWidget(sets_lbl) self.sets_combo = QComboBox() self.sets_combo.setMinimumWidth(120) sets_row.addWidget(self.sets_combo, 1) self.apply_set_btn = QPushButton("Apply") self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest") self.apply_set_btn.clicked.connect(self._apply_selected_set) sets_row.addWidget(self.apply_set_btn) self.save_set_btn = QPushButton("Save set…") self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set") self.save_set_btn.clicked.connect(self._save_set) sets_row.addWidget(self.save_set_btn) self.del_set_btn = QPushButton("−") self.del_set_btn.setToolTip("Delete the selected set") self.del_set_btn.setMaximumWidth(32) self.del_set_btn.clicked.connect(self._delete_set) sets_row.addWidget(self.del_set_btn) v.addLayout(sets_row) # Active and Disabled sections live in a vertical splitter so the user # can drag the divider instead of being stuck with a fixed-height # disabled list. active_box = QWidget() av = QVBoxLayout(active_box) av.setContentsMargins(0, 0, 0, 0) av.setSpacing(8) self.active_label = QLabel("Active") self.active_label.setObjectName("section") av.addWidget(self.active_label) self.active_table = self._make_server_table() av.addWidget(self.active_table, 1) self.active_empty = QLabel("No active servers. Add one, or Paste JSON.") self.active_empty.setObjectName("placeholder") self.active_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) self.active_empty.hide() av.addWidget(self.active_empty) disabled_box = QWidget() dv = QVBoxLayout(disabled_box) dv.setContentsMargins(0, 0, 0, 0) dv.setSpacing(8) self.disabled_label = QLabel("⊘ Disabled") self.disabled_label.setObjectName("sectionDisabled") dv.addWidget(self.disabled_label) self.disabled_table = self._make_server_table(object_name="disabledTable") dv.addWidget(self.disabled_table, 1) self.disabled_empty = QLabel("Nothing disabled.") self.disabled_empty.setObjectName("placeholder") self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter) dv.addWidget(self.disabled_empty) vsplit = QSplitter(Qt.Orientation.Vertical) vsplit.setChildrenCollapsible(False) vsplit.setHandleWidth(10) vsplit.addWidget(active_box) vsplit.addWidget(disabled_box) vsplit.setStretchFactor(0, 3) vsplit.setStretchFactor(1, 1) vsplit.setSizes([360, 150]) self.vsplit = vsplit v.addWidget(vsplit, 1) return card # --- bottom action bar ---------------------------------------------- # def _build_actionbar(self): bar = QHBoxLayout() self.add_btn = QPushButton("+ Add") self.dup_btn = QPushButton("Duplicate") self.del_btn = QPushButton("Delete") self.del_btn.setObjectName("danger") self.paste_btn = QPushButton("Paste JSON...") self.copy_btn = QPushButton("Copy to ▸") self.undo_btn = QPushButton("Undo") self.undo_btn.setEnabled(False) self.undo_btn.setToolTip("Undo last change (Ctrl+Z)") self.test_all_btn = QPushButton("Test all") self.test_all_btn.setToolTip( "Spawn-test every enabled local server, one at a time, and fill in the Health column" ) self.add_btn.clicked.connect(self.add_server) self.dup_btn.clicked.connect(self.duplicate_server) self.del_btn.clicked.connect(self.delete_server) self.paste_btn.clicked.connect(self.paste_json) self.copy_btn.clicked.connect(self.copy_to_menu) self.undo_btn.clicked.connect(self._undo) self.test_all_btn.clicked.connect(self._test_all_servers) for b in ( self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn, self.undo_btn, self.test_all_btn, ): bar.addWidget(b) # Ctrl+Z shortcut undo_action = QAction(self) undo_action.setShortcut("Ctrl+Z") undo_action.triggered.connect(self._undo) self.addAction(undo_action) # Ctrl+S / Cmd+S shortcut — routed through a guard so it respects # the same dirty/validation gating as the Save button. save_action = QAction(self) save_action.setShortcut(QKeySequence.StandardKey.Save) save_action.triggered.connect(self._save_shortcut) self.addAction(save_action) bar.addStretch() self.validation_lbl = QLabel("") bar.addWidget(self.validation_lbl) return bar # --- undo ------------------------------------------------------------ # def _push_undo(self): """Snapshot the current server list onto the undo stack.""" self._undo_stack.append( [core.ServerEntry(s.name, dict(s.data), s.enabled) for s in self.servers] ) if len(self._undo_stack) > 50: self._undo_stack.pop(0) self.undo_btn.setEnabled(True) def _undo(self): if not self._undo_stack: return self.servers = self._undo_stack.pop() self.undo_btn.setEnabled(bool(self._undo_stack)) cur = self._current_index() sel = cur if 0 <= cur < len(self.servers) else (0 if self.servers else -1) self._refresh_tables(select_index=sel) self._mark_dirty() self.status.setText("Undone.") def _set_all_enabled(self, enabled: bool): """Flip every server's enabled flag in one step (one undo snapshot).""" if not self.servers or all(s.enabled == enabled for s in self.servers): return # nothing to change cur = self._current_index() self._push_undo() for s in self.servers: s.enabled = enabled sel = cur if 0 <= cur < len(self.servers) else None self._refresh_tables(select_index=sel) self._mark_dirty() self.status.setText("All servers enabled." if enabled else "All servers disabled.") # --- profiles -------------------------------------------------------- # def reload_profiles(self): discovered = core.discover_profiles() # keep any manually-added custom profiles around customs = [p for p in self.profiles if str(p.path) not in {str(d.path) for d in discovered}] self.profiles = discovered + customs self.profile_combo.blockSignals(True) self.profile_combo.clear() for p in self.profiles: tag = "" if p.config_exists else " (no config yet)" self.profile_combo.addItem(f"{p.label}{tag}") # Full path in the tooltip so a profile is always verifiable even # when two labels look alike (e.g. two repos both named "app"). self.profile_combo.setItemData( self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole ) self.profile_combo.blockSignals(False) if self.profiles: self.profile_combo.setCurrentIndex(0) self.load_profile(self.profiles[0]) else: self.status.setText('No Claude installs found. Use "Add config…" to point at one.') self._maybe_warn_msix() def _maybe_warn_msix(self): """ Windows-only, no-op everywhere else: if Claude Desktop looks like an MSIX/Store install with a virtualized config, show a persistent banner so edits to the plain %APPDATA% path aren't silently lost. (The status bar is the wrong home for this: it's rewritten on every action.) Defensive on purpose -- this must never block startup or profile load. """ try: warning = core.msix_warning_text() except Exception: return if warning: self.warn_banner.setText(f"⚠ {warning}") self.warn_banner.setToolTip(warning) self.warn_banner.show() else: self.warn_banner.hide() def add_custom_config(self): start = str(core.app_support_base()) path, _ = QFileDialog.getOpenFileName( self, "Select a claude_desktop_config.json", start, "JSON (*.json);;All files (*)" ) if not path: return prof = core.profile_from_path(path) if str(prof.path) in {str(p.path) for p in self.profiles}: idx = [str(p.path) for p in self.profiles].index(str(prof.path)) self.profile_combo.setCurrentIndex(idx) return self.profiles.append(prof) self.profile_combo.addItem(f"{prof.label} (custom)") self.profile_combo.setCurrentIndex(self.profile_combo.count() - 1) def _profile_selected(self, idx): if 0 <= idx < len(self.profiles): self.load_profile(self.profiles[idx]) def load_profile(self, profile: core.Profile | None, confirm=False): if profile is None: return if confirm and self.dirty and not self._confirm_discard(): return repaired = False try: self.full_config = core.load_config(profile.path) except Exception as e: # Strict parse failed — see if the repair pipeline can salvage it, # and let the user decide with full visibility of the changes. try: cfg, notes, preview = core.repair_config_file(profile.path) except Exception: QMessageBox.critical( self, "Could not read config", f"{profile.path}\n\n{e}\n\nThe file couldn't be repaired automatically. " f"Fix it by hand or pick another install.", ) return dlg = RepairDialog(self, profile.path, str(e), notes, preview) if not dlg.exec(): return self.full_config = cfg repaired = True # extract_servers tolerates malformed entries rather than raising (#72), # but keep it inside the guard: a load failure must leave the previously # loaded profile intact instead of half-swapping the window's state. try: servers = core.extract_servers(self.full_config, profile.client) except Exception as exc: # pragma: no cover - defence in depth QMessageBox.critical( self, "Could not read config", f"{profile.path}\n\nThe server list couldn't be read: {exc}", ) return self._loaded_stat = core.config_fingerprint(profile.path) self.current_profile = profile self.servers = servers self.dirty = False self.restart_btn.hide() self._undo_stack.clear() self.undo_btn.setEnabled(False) self._health.clear() # health results are per-profile; a fresh load invalidates them self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file self._refresh_tables(select_index=0 if self.servers else -1) self.reload_banner.hide() # loaded fresh: any external-change notice is now stale self._rewatch_paths() # watch this profile's config + the selection's sidecar (#101) self._update_status(saved=False) if repaired: self._mark_dirty() self.status.setText( f"{profile.path} · repaired config loaded — press Save to write the " f"fix (the original file will be backed up)" ) # --- table rendering ------------------------------------------------- # def _add_row(self, table, master_idx, s): r = table.rowCount() table.insertRow(r) on = QTableWidgetItem() on.setFlags( Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable ) on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked) table.setItem(r, 0, on) name_item = QTableWidgetItem(s.name) name_item.setData(Qt.ItemDataRole.UserRole, master_idx) # row -> master list index if not s.enabled: name_item.setForeground(QColor(MUTED)) table.setItem(r, 1, name_item) type_item = QTableWidgetItem("remote" if s.kind == "remote" else "local") if not s.enabled: type_item.setForeground(QColor(MUTED)) table.setItem(r, 2, type_item) dep = core.check_dependency(s.data) st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") st.setForeground( QColor(STATUS_COLORS.get(dep["status"], MUTED)) ) # status stays colored even when off table.setItem(r, 3, st) health_status, health_summary = self._health.get(s.name, (core.HealthStatus.UNTESTED, "")) if s.kind == "remote" and s.name not in self._health: health_summary = "remote server — use “Test connection” in the editor" health_item = QTableWidgetItem(HEALTH_GLYPH.get(health_status, "○")) health_item.setForeground(QColor(HEALTH_COLORS.get(health_status, MUTED))) health_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter) health_item.setToolTip(health_summary or health_status) table.setItem(r, 4, health_item) self._row_of_index[master_idx] = (table, r) def _refresh_tables(self, select_index=None): self._suppress_table = True self._suppress_sel = True self._row_of_index = {} self.active_table.setRowCount(0) self.disabled_table.setRowCount(0) query = self._filter_query n_active = n_disabled = 0 total_active = total_disabled = 0 for i, s in enumerate(self.servers): if s.enabled: total_active += 1 else: total_disabled += 1 if not core.server_matches_filter(s, query): continue if s.enabled: self._add_row(self.active_table, i, s) n_active += 1 else: self._add_row(self.disabled_table, i, s) n_disabled += 1 self.active_table.setVisible(n_active > 0) self.active_empty.setVisible(n_active == 0) self.active_empty.setText( "No matches." if query.strip() and total_active and not n_active else "No active servers. Add one, or Paste JSON." ) self.disabled_table.setVisible(n_disabled > 0) self.disabled_empty.setVisible(n_disabled == 0) self.disabled_empty.setText( "No matches." if query.strip() and total_disabled and not n_disabled else "Nothing disabled." ) self._refresh_badges() self._suppress_table = False self._suppress_sel = False if select_index is not None and select_index in self._row_of_index: table, row = self._row_of_index[select_index] self._select_in(table, row) else: self._load_editor_from_selection() self._validate() # --- search / filter -------------------------------------------------- # def _on_search_changed(self, text): self._filter_query = text cur = self._current_index() self._refresh_tables(select_index=cur if cur >= 0 else None) # --- named server sets (issue #52) ------------------------------------ # def _refresh_sets_combo(self, select: str | None = None): sets = core.list_server_sets(self.full_config) self.sets_combo.blockSignals(True) self.sets_combo.clear() for name in sorted(sets): self.sets_combo.addItem(name) if select is not None: idx = self.sets_combo.findText(select) if idx >= 0: self.sets_combo.setCurrentIndex(idx) self.sets_combo.blockSignals(False) has_sets = bool(sets) self.apply_set_btn.setEnabled(has_sets) self.del_set_btn.setEnabled(has_sets) def _apply_selected_set(self): name = self.sets_combo.currentText() sets = core.list_server_sets(self.full_config) if name not in sets: return self._push_undo() missing = core.apply_server_set(self.servers, sets[name]) self._refresh_tables(select_index=self._current_index() if self.servers else None) self._mark_dirty() on = sum(1 for s in self.servers if s.enabled) msg = f"Applied set “{name}” · {on} enabled. Review and Save." if missing: msg += f" ⚠ no longer in this config: {', '.join(missing)}" self.status.setText(msg) def _save_set(self): name, ok = QInputDialog.getText( self, "Save server set", "Set name (saves which servers are currently Active):", text=self.sets_combo.currentText(), ) name = name.strip() if not ok or not name: return if name in core.list_server_sets(self.full_config) and ( QMessageBox.question(self, "Set exists", f"Replace set “{name}”?") != QMessageBox.StandardButton.Yes ): return members = core.save_server_set(self.full_config, name, self.servers) self._refresh_sets_combo(select=name) self._mark_dirty() # the set is written on the next Save self.status.setText( f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it." ) def _delete_set(self): name = self.sets_combo.currentText() if not name: return if ( QMessageBox.question(self, "Delete set", f"Delete set “{name}”?") != QMessageBox.StandardButton.Yes ): return if core.delete_server_set(self.full_config, name): self._refresh_sets_combo() self._mark_dirty() self.status.setText(f"Set “{name}” deleted. Press Save to write the change.") # --- test all (spawn-test every enabled local server) ---------------- # def _test_all_servers(self): targets = [s for s in self.servers if s.enabled and s.kind == "stdio"] if not targets: self.status.setText("No enabled local servers to test.") return self._test_all_queue = list(targets) self._test_all_total = len(targets) self._test_all_done = 0 self.test_all_btn.setEnabled(False) self.test_all_btn.setText(f"Testing 0/{self._test_all_total}…") self._run_next_health_test() def _run_next_health_test(self): if not self._test_all_queue: self.test_all_btn.setEnabled(True) self.test_all_btn.setText("Test all") self.status.setText(f"Tested {self._test_all_done} server(s).") return entry = self._test_all_queue.pop(0) self._health_tester = SpawnTester(dict(entry.data), timeout=3.0) self._health_tester.done.connect( lambda result, name=entry.name: self._on_health_test_done(name, result) ) self._health_tester.start() def _on_health_test_done(self, name, result): status, summary = core.health_from_spawn_result(result) self._health[name] = (status, summary) self._test_all_done += 1 self.test_all_btn.setText(f"Testing {self._test_all_done}/{self._test_all_total}…") self._update_health_cell(name, status, summary) self._run_next_health_test() def _update_health_cell(self, name, status, summary): idx = next((i for i, s in enumerate(self.servers) if s.name == name), None) if idx is None or idx not in self._row_of_index: return table, row = self._row_of_index[idx] item = table.item(row, 4) if item is None: return item.setText(HEALTH_GLYPH.get(status, "○")) item.setForeground(QColor(HEALTH_COLORS.get(status, MUTED))) item.setToolTip(summary or status) def _section_html(self, title, n, miss, warn): base = f"{title} · {n}" if miss: return f'{base}   ▲ {miss} broken' if warn: return f'{base}   ▲ {warn} PATH-risk' return base def _refresh_badges(self): n_a = n_d = a_miss = a_warn = d_miss = d_warn = 0 a_names, d_names = [], [] for s in self.servers: st = core.check_dependency(s.data)["status"] if s.enabled: n_a += 1 if st == "missing": a_miss += 1 a_names.append(s.name) elif st == "warn": a_warn += 1 a_names.append(s.name) else: n_d += 1 if st == "missing": d_miss += 1 d_names.append(s.name) elif st == "warn": d_warn += 1 d_names.append(s.name) self.active_label.setText(self._section_html("Active", n_a, a_miss, a_warn)) self.active_label.setToolTip(("Needs attention: " + ", ".join(a_names)) if a_names else "") self.disabled_label.setText(self._section_html("⊘ Disabled", n_d, d_miss, d_warn)) self.disabled_label.setToolTip( ("Needs attention: " + ", ".join(d_names)) if d_names else "" ) def _select_in(self, table, row): self._suppress_sel = True other = self.disabled_table if table is self.active_table else self.active_table other.clearSelection() other.setCurrentCell(-1, -1) self._suppress_sel = False self._focused_table = table table.selectRow(row) # fires _on_selection -> loads editor # --- selection across the two tables -------------------------------- # def _on_selection(self, table): if self._suppress_sel: return if table.currentRow() < 0: return self._suppress_sel = True other = self.disabled_table if table is self.active_table else self.active_table other.clearSelection() other.setCurrentCell(-1, -1) self._suppress_sel = False self._focused_table = table self._load_editor_from_selection() def _current_index(self) -> int: t = self._focused_table if t is None: return -1 r = t.currentRow() if r < 0: return -1 item = t.item(r, 1) if item is None: return -1 idx = item.data(Qt.ItemDataRole.UserRole) return idx if idx is not None else -1 def _load_editor_from_selection(self): idx = self._current_index() entry = self.servers[idx] if 0 <= idx < len(self.servers) else None self.editor.load_entry(entry) self.copy_btn.setEnabled(entry is not None and len(self.profiles) > 1) self.dup_btn.setEnabled(entry is not None) self.del_btn.setEnabled(entry is not None) self._rewatch_paths() # --- hot-reload (#101) ------------------------------------------------- # def _watch_targets(self) -> list[Path]: """Every external path worth watching for the current view. BCC's own loaded config (+ its directory), plus the selected server's sidecar path(s) resolved from its ServerSpec. Directories are watched too so a file appearing/disappearing — which a watch on a not-yet-existent file would miss, and which an atomic-rename replace looks like — is still observed. All entries must exist for QFileSystemWatcher to accept them. """ targets: list[Path] = [] if self.current_profile is not None: cfg = Path(self.current_profile.path) targets.append(cfg) targets.append(cfg.parent) if self.editor.isEnabled(): data = self.editor.dump_data() targets.extend(core.sidecar_watch_paths(data)) # De-dup, keep only paths that currently exist (the watcher rejects the # rest; the parent dir covers a not-yet-created file). seen: set[str] = set() out: list[Path] = [] for p in targets: key = str(p) if key in seen: continue seen.add(key) if p.exists(): out.append(p) return out def _rewatch_paths(self): """Point the watcher at the current targets and snapshot sidecar state. Re-applied whenever the selection or the edited command/args change (which server is selected decides which sidecar to watch) and after a reload. """ watcher = self._fs_watcher existing = watcher.files() + watcher.directories() if existing: watcher.removePaths(existing) wanted = [str(p) for p in self._watch_targets()] if wanted: watcher.addPaths(wanted) self._sidecar_fp = self._current_sidecar_fp() def _current_sidecar_fp(self) -> tuple | None: if not self.editor.isEnabled(): return None return core.sidecar_state_fingerprint(self.editor.dump_data()) def _on_fs_signal(self, _path=None): """A watched path changed — coalesce a burst of writes via the debounce.""" self._fs_debounce.start() def _recheck_external_state(self): """Debounced re-check: re-arm the watcher, refresh advisories if state actually changed, and surface an external edit to BCC's own config. Runs from both the file-watcher and the focus-in fallback. Never mutates the editor form or the loaded config — the only content reload is the user pressing Reload on the banner, so unsaved edits are safe. """ # Re-arm: an atomic-rename replace drops the old inode from the watcher, # so paths must be re-added or the next change goes unseen. self._rewatch_after_event() # Sidecar/permission advisories for the selected server (#91/#93). Only # refresh when the observable state changed, so an unrelated write in the # watched directory doesn't thrash the panel. new_fp = self._current_sidecar_fp() if core.sidecar_state_changed(self._sidecar_fp, new_fp): self._sidecar_fp = new_fp self.editor.recheck_advisories() # BCC's own loaded config edited outside BCC: surface it (non-destructive). self._check_config_changed_on_disk() def _rewatch_after_event(self): """Re-add any wanted paths the watcher dropped, without disturbing the sidecar fingerprint (which _recheck_external_state compares itself).""" watcher = self._fs_watcher current = set(watcher.files()) | set(watcher.directories()) readd = [str(p) for p in self._watch_targets() if str(p) not in current] if readd: watcher.addPaths(readd) def _check_config_changed_on_disk(self): if self.current_profile is None: return disk = core.config_fingerprint(self.current_profile.path) if disk is not None and self._loaded_stat is not None and disk != self._loaded_stat: self.reload_banner.show_notice( f"{self.current_profile.path} changed on disk (edited outside BCC).", action_label="Reload from disk", on_action=lambda: self.load_profile(self.current_profile, confirm=True), ) else: # Back in sync (e.g. the user reloaded, or the change was reverted). self.reload_banner.hide() def changeEvent(self, event): """Re-check external state when the window regains focus (#101). A cheap, always-works fallback: QFileSystemWatcher can miss changes (notably atomic-rename replaces, and files that didn't exist when the watch was set), so a re-check on activation covers the gap. """ if event.type() == QEvent.Type.ActivationChange and self.isActiveWindow(): # Go through the debounce so activation + a watcher signal coalesce. self._fs_debounce.start() super().changeEvent(event) def _table_item_changed(self, item: QTableWidgetItem): if self._suppress_table or item.column() != 0: return table = item.tableWidget() name_item = table.item(item.row(), 1) if name_item is None: return idx = name_item.data(Qt.ItemDataRole.UserRole) if idx is None or not (0 <= idx < len(self.servers)): return self._push_undo() self.servers[idx].enabled = item.checkState() == Qt.CheckState.Checked # Toggling moves the server to the other section; keep it selected there. self._refresh_tables(select_index=idx) self._mark_dirty() # --- editor change --------------------------------------------------- # def _editor_changed(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return entry = self.servers[idx] old_name = entry.name entry.name = self.editor.current_name() entry.set_data(self.editor.dump_data()) # The server stays in its section (enable state unchanged), so update # its existing row in place rather than re-rendering. # An edit invalidates any cached "Test all" result -- the server that # was spawn-tested no longer matches what's on disk once saved. Pop # both names: the old one (so a rename doesn't leave a stale entry # for whoever takes that name next) and the new one (so we don't # inherit a stale result cached under the name being renamed to). self._health.pop(old_name, None) self._health.pop(entry.name, None) loc = self._row_of_index.get(idx) if loc: table, row = loc self._suppress_table = True table.item(row, 1).setText(entry.name) table.item(row, 2).setText("remote" if entry.kind == "remote" else "local") dep = core.check_dependency(entry.data) st = table.item(row, 3) st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) health_item = table.item(row, 4) if health_item is not None: health_item.setText(HEALTH_GLYPH.get(core.HealthStatus.UNTESTED, "○")) health_item.setForeground( QColor(HEALTH_COLORS.get(core.HealthStatus.UNTESTED, MUTED)) ) health_item.setToolTip("Not tested since last edit.") self._suppress_table = False self._refresh_badges() # The edited command/args may change which package (and thus which # sidecar) this server resolves to — re-point the watcher (#101). self._rewatch_paths() self._mark_dirty() # --- server actions -------------------------------------------------- # def add_server(self): self._push_undo() base = "new-server" name = base n = 2 existing = {s.name for s in self.servers} while name in existing: name = f"{base}-{n}" n += 1 self.servers.append(core.ServerEntry(name, {"command": "npx", "args": []}, True)) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.editor.name.setFocus() self.editor.name.selectAll() def duplicate_server(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return self._push_undo() src = self.servers[idx] existing = {s.name for s in self.servers} name = f"{src.name}-copy" n = 2 while name in existing: name = f"{src.name}-copy-{n}" n += 1 self.servers.insert(idx + 1, core.ServerEntry(name, dict(src.data), src.enabled)) self._refresh_tables(select_index=idx + 1) self._mark_dirty() def delete_server(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return name = self.servers[idx].name if ( QMessageBox.question(self, "Delete server", f"Remove “{name}”?") != QMessageBox.StandardButton.Yes ): return self._push_undo() del self.servers[idx] self._refresh_tables(select_index=min(idx, len(self.servers) - 1)) self._mark_dirty() def _import_server(self, name: str, data: dict) -> tuple[bool, bool]: """ Add a pasted/dropped `name`/`data` server to self.servers, resolving a name collision with an explicit prompt (never a silent overwrite). Returns (added, replaced). """ existing = {s.name: i for i, s in enumerate(self.servers)} if name in existing: ans = QMessageBox.question( self, "Server exists", f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if ans == QMessageBox.StandardButton.Yes: self.servers[existing[name]].set_data(data) return False, True name = core.resolve_name_collision(name, {s.name for s in self.servers}) self.servers.append(core.ServerEntry(name, data, True)) return True, False def paste_json(self): dlg = PasteDialog(self) if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers: return self._push_undo() added, replaced = 0, 0 for name, data in dlg.result_servers.items(): a, r = self._import_server(name, data) added += int(a) replaced += int(r) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.") def copy_to_menu(self): idx = self._current_index() if not (0 <= idx < len(self.servers)): return menu = QMenu(self) for p in self.profiles: if self.current_profile and str(p.path) == str(self.current_profile.path): continue act = QAction(p.label, self) act.triggered.connect(lambda _=False, prof=p: self._copy_to(prof, idx)) menu.addAction(act) if menu.isEmpty(): return self.copy_btn.setMenu(menu) self.copy_btn.showMenu() def _copy_to(self, dest: core.Profile, idx: int): src = self.servers[idx] try: dest_cfg = core.load_config(dest.path) except Exception as e: QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}") return existing = core.extract_servers(dest_cfg, dest.client) names = {s.name for s in existing} if src.name in names: ans = QMessageBox.question( self, "Exists in destination", f"“{src.name}” already exists in {dest.label}. Overwrite it there?", ) if ans != QMessageBox.StandardButton.Yes: return existing = [s for s in existing if s.name != src.name] existing.append(core.ServerEntry(src.name, dict(src.data), True)) core.apply_servers(dest_cfg, existing, dest.client) try: backup = core.write_config(dest.path, dest_cfg) except Exception as e: QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}") return bnote = f" (backup: {backup.name})" if backup else "" self.status.setText( f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply." ) # --- save ------------------------------------------------------------ # def _validate(self) -> bool: problems = core.validate_servers(self.servers) if problems: self.validation_lbl.setText(f"⚠ {problems[0]}") self.validation_lbl.setStyleSheet(f"color: {WARN};") self.save_btn.setEnabled(False) return False lint_warnings = core.lint_servers(self.servers) # ${VAR} references are only meaningful if the target client expands # them -- Claude Desktop doesn't, so the same config is fine in one # profile and broken in another (#76). Report against the loaded one. for entry in self.servers: for warning in core.env_ref_warnings(entry.data, self.current_profile): lint_warnings.append(f"'{entry.name}': {warning}") for warning in core.removed_flag_warnings(entry.data): lint_warnings.append(f"'{entry.name}': {warning}") if lint_warnings: self.validation_lbl.setText(f"⚠ {lint_warnings[0]}") self.validation_lbl.setStyleSheet(f"color: {WARN};") else: self.validation_lbl.setText("✓ valid") self.validation_lbl.setStyleSheet(f"color: {GOOD};") self.save_btn.setEnabled(self.dirty) return True def _save_shortcut(self): """Ctrl+S / Cmd+S handler — only fires when the Save button itself would accept a click, so the shortcut can't bypass validation/dirty gating.""" if self.save_btn.isEnabled(): self.save() def save(self): if not self.current_profile: return if not self._validate(): QMessageBox.warning(self, "Can't save yet", "Fix the highlighted problem first.") return # Stale-file check: if the file changed on disk since we loaded it, prompt. # Compare mtime AND size (not mtime alone) so a concurrent external write # that lands within the mtime resolution window, or that restores the # original mtime, still gets caught. disk_stat = core.config_fingerprint(self.current_profile.path) if ( disk_stat is not None and self._loaded_stat is not None and disk_stat != self._loaded_stat ): changed_keys, server_diff = core.external_change_summary( self.full_config, self.current_profile.path, self.current_profile.client ) dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff) if not dlg.exec(): return # user cancelled if dlg.choice() == StaleDialog.MERGE: try: fresh = core.load_config(self.current_profile.path) except Exception as e: QMessageBox.critical(self, "Reload failed", str(e)) return # The reload above is the on-disk truth for everything the user # didn't touch -- but it also wipes BCC-authored keys the user # changed in this session (named sets), which apply_servers # doesn't write. Carry them over before saving (#73). contested = core.carry_owned_keys(self.full_config, fresh) core.apply_servers(fresh, self.servers, self.current_profile.client) try: backup = core.write_config(self.current_profile.path, fresh) except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return self.full_config = fresh self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) bnote = f" · backup: {backup.name}" if backup else " · (new file)" cnote = ( f" · kept your {', '.join(contested)} (the file on disk had a different copy)" if contested else "" ) self.status.setText( f"Merged & saved {self.current_profile.path}{bnote}{cnote}" f" · Restart {self.current_profile.label} to apply." ) self._offer_restart_button() return # else OVERWRITE: fall through to normal write core.apply_servers(self.full_config, self.servers, self.current_profile.client) try: backup = core.write_config(self.current_profile.path, self.full_config) except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) bnote = f" · backup: {backup.name}" if backup else " · (new file)" self.status.setText( f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply." ) self._offer_restart_button() # --- restart Claude Desktop (issue #9) -------------------------------- # def _offer_restart_button(self): """Show the 'Restart Claude Desktop' button after a successful save, but only when the just-saved profile is Claude Desktop -- restarting makes no sense for Claude Code, which has no GUI process to bounce -- and only on platforms where Claude Desktop exists (never Linux, where 'claude' is the Claude Code CLI).""" if ( self.current_profile and core.profile_targets_claude_desktop(self.current_profile) and core.restart_supported() ): self.restart_btn.show() else: self.restart_btn.hide() def _restart_claude_desktop(self): self.restart_btn.setEnabled(False) self.restart_btn.setText("Restarting…") # Held on self (MainWindow outlives the worker); replaced only after # done re-enables the button, so a running thread is never dropped. self._restart_worker = RestartWorker() self._restart_worker.done.connect(self._on_restart_done) self._restart_worker.start() def _on_restart_done(self, result): self.restart_btn.setEnabled(True) self.restart_btn.setText("Restart Claude Desktop") self.restart_btn.hide() if result.success: self.status.setText(f"{self.status.text()} · {result.detail}") else: QMessageBox.warning(self, "Restart failed", result.detail) def _restore_from_backup(self): if not self.current_profile: return if self.dirty and not self._confirm_discard(): return # full_config is the right base: discarded edits were server changes; # non-server keys from the in-memory state are what we want to preserve. dlg = RestoreDialog(self, self.current_profile, self.full_config) if not dlg.exec(): return bp = dlg.selected_backup() if not bp: return try: core.restore_backup(self.current_profile.path, bp, current_cfg=self.full_config) except Exception as e: QMessageBox.critical(self, "Restore failed", str(e)) return # Reload from disk so the UI reflects the restored state self.load_profile(self.current_profile, confirm=False) self.status.setText( f"Restored from {Path(bp).name} · Restart {self.current_profile.label} to apply." ) # --- dirty / status -------------------------------------------------- # def _mark_dirty(self): self.dirty = True self.restart_btn.hide() self._validate() self._update_status(saved=False) def _update_status(self, saved): if self.current_profile: n = len(self.servers) on = sum(1 for s in self.servers if s.enabled) d = " · unsaved changes" if self.dirty else "" self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}") def _confirm_discard(self) -> bool: return ( QMessageBox.question( self, "Discard changes?", "You have unsaved changes. Discard them?" ) == QMessageBox.StandardButton.Yes ) # --- drag & drop a .json file to import ------------------------------ # def dragEnterEvent(self, e): if e.mimeData().hasUrls() and any( u.toLocalFile().endswith(".json") for u in e.mimeData().urls() ): e.acceptProposedAction() def dropEvent(self, e): total_added, total_replaced, files_imported = 0, 0, 0 undo_pushed = False for u in e.mimeData().urls(): path = u.toLocalFile() if not path.endswith(".json"): continue p = Path(path) if p.stat().st_size > MAX_DROP_IMPORT_BYTES: QMessageBox.warning( self, "File too large", f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.", ) continue text = p.read_text(encoding="utf-8") try: servers = core.parse_pasted_json(text) except Exception as ex: QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}") continue if not undo_pushed: self._push_undo() undo_pushed = True added, replaced = 0, 0 for name, data in servers.items(): a, r = self._import_server(name, data) added += int(a) replaced += int(r) total_added += added total_replaced += replaced files_imported += 1 if files_imported: self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText( f"Imported {total_added} added, {total_replaced} replaced from " f"{files_imported} file(s). Review and Save." ) def closeEvent(self, e): if self.dirty and not self._confirm_discard(): e.ignore() return self._save_layout() e.accept() def system_is_dark(app: QApplication) -> bool: """Whether the desktop is currently using a dark appearance. Read from the style's own window colour rather than per-platform APIs -- Qt has already resolved the OS appearance by the time it builds the default palette, so this works the same on all three platforms. """ try: return app.palette().color(QPalette.ColorRole.Window).lightness() < 128 except Exception: # pragma: no cover - defensive; never block startup on theming return True def stored_theme_setting() -> str: """The user's theme choice, defaulting to following the system.""" value = QSettings("BCC", "BetterClaudeConfig").value("ui/theme", core.THEME_SYSTEM) return value if value in core.THEME_CHOICES else core.THEME_SYSTEM def theme_stylesheet_for(app: QApplication, setting: str | None = None) -> str: """Resolve setting + OS appearance into a palette, apply it, return the QSS.""" if setting is None: setting = stored_theme_setting() theme = core.resolve_theme(setting, system_is_dark(app)) return apply_palette(core.palette_for(theme)) def main(): if sys.platform == "win32": # Without an explicit AppUserModelID, Windows taskbar groups the app # under the default host/Python icon instead of our own window icon. try: import ctypes ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID( "io.avezzano.better-claude-config" ) except Exception: pass app = QApplication(sys.argv) app.setApplicationName("Better Claude Config") app.setApplicationDisplayName("Better Claude Config") icon = _app_icon() if not icon.isNull(): app.setWindowIcon(icon) app.setStyleSheet(theme_stylesheet_for(app)) win = MainWindow() win.show() sys.exit(app.exec()) if __name__ == "__main__": main()