""" 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 sys import time from pathlib import Path from typing import ClassVar from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal from PySide6.QtGui import ( QAction, QColor, QCursor, QDesktopServices, QGuiApplication, QIcon, QPainter, QPixmap, ) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFrame, QGridLayout, QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, QPlainTextEdit, QPushButton, 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 # --- One-line rebrand: change this to recolor the whole app --------------- # ACCENT = "#f97316" # warm orange ACCENT_DIM = "#c2570b" BG = "#1b1d23" PANEL = "#23262e" PANEL_2 = "#2b2f39" TEXT = "#e7e9ee" MUTED = "#9aa0ad" BORDER = "#3a3f4b" GOOD = "#4ade80" BAD = "#f87171" WARN = "#fbbf24" STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN} STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"} # 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_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED} HEALTH_GLYPH = {"ok": "●", "failed": "●", "untested": "○"} STYLESHEET = 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: {TEXT}; }} QMainWindow, QDialog {{ background: {BG}; }} QLabel#h1 {{ font-size: 15px; font-weight: 600; }} QLabel#muted {{ color: {MUTED}; }} QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }} QLineEdit, QPlainTextEdit, QComboBox {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205; }} QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }} QComboBox::drop-down {{ border: none; width: 22px; }} QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER}; selection-background-color: {ACCENT}; outline: none; }} QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; padding: 7px 13px; }} QPushButton:hover {{ border: 1px solid {ACCENT}; }} QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }} QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }} QPushButton#primary:hover {{ background: {ACCENT_DIM}; }} QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }} QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }} QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; gridline-color: transparent; outline: none; }} QTableWidget::item {{ padding: 6px 8px; border: none; }} QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }} /* 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: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT}; border-radius: 3px; padding: 0px 4px; margin: 0px; selection-background-color: {ACCENT_DIM}; selection-color: #ffffff; }} QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none; border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }} QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }} QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QTableWidget#disabledTable {{ background: #202229; }} QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }} QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }} QFrame#diagCard {{ background: transparent; border: none; }} QSplitter::handle {{ background: transparent; }} QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }} QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }} """ # --------------------------------------------------------------------------- # # 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.is_secret_key(key_item.text()): option.text = core.MASK 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) # 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 _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.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.details_btn) dep.addWidget(recheck) outer.addLayout(dep) # 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) 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 # --- 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._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() # --- 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() 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() 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 # --- dependency ------------------------------------------------------ # def refresh_dependency(self, auto_open=False): if not self.isEnabled(): self._set_dep({"status": "unknown", "label": "—"}) self.diag_text.clear() 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()) 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() 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)) # --------------------------------------------------------------------------- # # 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 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) # 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) 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): help_menu = self.menuBar().addMenu("&Help") about_action = QAction("About Better Claude Config…", self) about_action.triggered.connect(self._show_about) help_menu.addAction(about_action) 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()) if core.is_newer_version(core.__version__, release["version"]): self.status.setText( f"Update available: {release['version']} · Help ▸ About to view it." ) # --- 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) 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) v.addWidget(self.search_box) # 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) 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.") # --- 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}") 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 self._loaded_stat = core.config_fingerprint(profile.path) self.current_profile = profile self.servers = core.extract_servers(self.full_config) 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._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) 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.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() 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]].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) 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) 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) 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(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 ) 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 core.apply_servers(fresh, self.servers) 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)" self.status.setText( f"Merged & saved {self.current_profile.path}{bnote}" 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) 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 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(STYLESHEET) win = MainWindow() win.show() sys.exit(app.exec()) if __name__ == "__main__": main()