67c898cd35
- Add a real QMenuBar (Help -> About...). - AboutDialog: app icon, name, version (core.__version__), and links to the repo/issues/license opened via QDesktopServices.openUrl (system browser, never in-app navigation). macOS unsigned-app note. - "Check for updates" button + UpdateCheckWorker (QThread) runs core.fetch_latest_release() off the UI thread; shows "up to date" or "vX.Y.Z available" with a button to open the releases page. No binary download, ever. - Optional quiet startup auto-check, throttled to once/day via a QSettings timestamp, off-thread, silent on failure, toggle lives in the About dialog.
2076 lines
81 KiB
Python
2076 lines
81 KiB
Python
"""
|
||
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.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.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())
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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"<b>{path}</b><br>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 <b>mcpServers</b> and "
|
||
"<b>_disabledMcpServers</b> 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"<b>{profile_path}</b> was modified on disk since you loaded it.")
|
||
intro.setWordWrap(True)
|
||
v.addWidget(intro)
|
||
|
||
if changed_keys:
|
||
kl = QLabel("Changed keys: " + ", ".join(f"<code>{k}</code>" 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(
|
||
"<b>Merge & save</b> applies your server edits on top of the current file "
|
||
"on disk — both sets of changes are kept. "
|
||
"<b>Overwrite</b> 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))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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_mtime: float | 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())
|
||
|
||
self.status = QLabel("Ready.")
|
||
self.status.setObjectName("statusbar")
|
||
root.addWidget(self.status)
|
||
|
||
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_mtime = core.config_mtime(profile.path)
|
||
self.current_profile = profile
|
||
self.servers = core.extract_servers(self.full_config)
|
||
self.dirty = False
|
||
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} <span style="color:{BAD};">▲ {miss} broken</span>'
|
||
if warn:
|
||
return f'{base} <span style="color:{WARN};">▲ {warn} PATH-risk</span>'
|
||
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 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
|
||
existing = {s.name: i for i, s in enumerate(self.servers)}
|
||
for name, data in dlg.result_servers.items():
|
||
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
|
||
replaced += 1
|
||
continue
|
||
new = f"{name}-2"
|
||
k = 3
|
||
names = {s.name for s in self.servers}
|
||
while new in names:
|
||
new = f"{name}-{k}"
|
||
k += 1
|
||
name = new
|
||
self.servers.append(core.ServerEntry(name, data, True))
|
||
added += 1
|
||
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.
|
||
disk_mtime = core.config_mtime(self.current_profile.path)
|
||
if (
|
||
disk_mtime is not None
|
||
and self._loaded_mtime is not None
|
||
and disk_mtime != self._loaded_mtime
|
||
):
|
||
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_mtime = core.config_mtime(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."
|
||
)
|
||
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_mtime = core.config_mtime(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."
|
||
)
|
||
|
||
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._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()
|
||
for name, data in servers.items():
|
||
names = {s.name for s in self.servers}
|
||
if name in names:
|
||
name = f"{name}-imported"
|
||
self.servers.append(core.ServerEntry(name, data, True))
|
||
self._refresh_tables(select_index=len(self.servers) - 1)
|
||
self._mark_dirty()
|
||
self.status.setText(
|
||
f"Imported {len(servers)} server(s) 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()
|