"""
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 PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import (
QAction,
QColor,
QDesktopServices,
QGuiApplication,
QIcon,
QPainter,
QPixmap,
)
from PySide6.QtWidgets import (
QAbstractItemView,
QApplication,
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFrame,
QGridLayout,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QListWidget,
QListWidgetItem,
QMainWindow,
QMenu,
QMessageBox,
QPlainTextEdit,
QPushButton,
QSplitter,
QStackedWidget,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
import bcc_core as core
# --- 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": "○"}
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#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.
"""
done = Signal(object) # dict | None
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, *_):
if self._on_change:
self._on_change()
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)
# 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)
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
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:
return
if self._before_change:
self._before_change()
r = self.table.rowCount()
self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(k))
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)
self.table.setItem(r, 0, QTableWidgetItem(str(k)))
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)
# --------------------------------------------------------------------------- #
# 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 = (
Path(__file__).resolve().parent / "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))
# --------------------------------------------------------------------------- #
# 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
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(14, 14, 14, 10)
root.setSpacing(12)
root.addLayout(self._build_topbar())
# 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
st.setValue("update/lastCheck", time.time())
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 and 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, 4)
if object_name:
t.setObjectName(object_name)
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"])
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)
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)
# 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.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)
for b in (
self.add_btn,
self.dup_btn,
self.del_btn,
self.paste_btn,
self.copy_btn,
self.undo_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.')
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._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)
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)
n_active = n_disabled = 0
for i, s in enumerate(self.servers):
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.disabled_table.setVisible(n_disabled > 0)
self.disabled_empty.setVisible(n_disabled == 0)
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()
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]
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.
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)))
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
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."""
if self.current_profile and core.profile_targets_claude_desktop(self.current_profile):
self.restart_btn.show()
else:
self.restart_btn.hide()
def _restart_claude_desktop(self):
self.restart_btn.setEnabled(False)
try:
result = core.restart_claude_desktop()
finally:
self.restart_btn.setEnabled(True)
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):
for u in e.mimeData().urls():
path = u.toLocalFile()
if not path.endswith(".json"):
continue
text = Path(path).read_text(encoding="utf-8")
try:
servers = core.parse_pasted_json(text)
except Exception as ex:
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}")
continue
self._push_undo()
added, replaced = 0, 0
for name, data in 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 from {Path(path).name}. "
"Review and Save."
)
break
def closeEvent(self, e):
if self.dirty and not self._confirm_discard():
e.ignore()
return
self._save_layout()
e.accept()
def main():
app = QApplication(sys.argv)
app.setApplicationName("Better Claude Config")
app.setApplicationDisplayName("Better Claude Config")
app.setStyleSheet(STYLESHEET)
win = MainWindow()
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()