3c99ff547b
'-apple-system' is a web convention, not a real font family — Qt scans every installed font trying to resolve it (the 'Populating font family aliases' warning at startup). Qt already defaults to the native system UI font on each platform, so don't name UI fonts at all. The diag panel's 'SF Mono' (not system-installed on macOS) becomes Menlo.
1516 lines
59 KiB
Python
1516 lines
59 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
|
||
from pathlib import Path
|
||
|
||
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal
|
||
from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter
|
||
from PySide6.QtWidgets import (
|
||
QAbstractItemView,
|
||
QApplication,
|
||
QComboBox,
|
||
QDialog,
|
||
QDialogButtonBox,
|
||
QFileDialog,
|
||
QFrame,
|
||
QGridLayout,
|
||
QHBoxLayout,
|
||
QHeaderView,
|
||
QLabel,
|
||
QLineEdit,
|
||
QMainWindow,
|
||
QMenu,
|
||
QMessageBox,
|
||
QPlainTextEdit,
|
||
QPushButton,
|
||
QSplitter,
|
||
QStackedWidget,
|
||
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)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Small reusable: key/value editor (for env and headers)
|
||
# --------------------------------------------------------------------------- #
|
||
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)
|
||
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)
|
||
row.addWidget(add)
|
||
row.addWidget(rem)
|
||
row.addStretch()
|
||
lay.addLayout(row)
|
||
|
||
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)
|
||
|
||
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
|
||
|
||
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.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.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)
|
||
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._set_dep({"status": "unknown", "label": "—"})
|
||
self.args_warn.hide()
|
||
self.args_fix_btn.hide()
|
||
self._loading = False
|
||
return
|
||
self.setEnabled(True)
|
||
d = entry.data
|
||
self._extra = {k: v for k, v in d.items() if k not in core.KNOWN_FIELDS}
|
||
self.name.setText(entry.name)
|
||
# Reset BOTH pages first so a previously-selected server's values can't
|
||
# leak in if the user flips the type toggle mid-edit.
|
||
self.command.clear()
|
||
self.args.clear()
|
||
self.env.load({})
|
||
self.url.clear()
|
||
self.transport.setCurrentIndex(0)
|
||
self.headers.load({})
|
||
if entry.kind == "remote":
|
||
self.type.setCurrentIndex(1)
|
||
self.stack.setCurrentIndex(1)
|
||
self.url.setText(str(d.get("url", "")))
|
||
t = d.get("type")
|
||
self.transport.setCurrentText(t if t in ("http", "sse") else "(auto)")
|
||
self.headers.load(d.get("headers", {}))
|
||
else:
|
||
self.type.setCurrentIndex(0)
|
||
self.stack.setCurrentIndex(0)
|
||
self.command.setText(str(d.get("command", "")))
|
||
self.args.setPlainText("\n".join(str(a) for a in (d.get("args") or [])))
|
||
self.env.load(d.get("env", {}))
|
||
self._loading = False
|
||
self.refresh_dependency(auto_open=True)
|
||
self._check_args()
|
||
|
||
def dump_data(self) -> dict:
|
||
"""Build the server dict from the form, preserving unknown fields."""
|
||
data = dict(self._extra)
|
||
if self.type.currentIndex() == 1: # remote
|
||
data["url"] = self.url.text().strip()
|
||
t = self.transport.currentText()
|
||
if t in ("http", "sse"):
|
||
data["type"] = t
|
||
else:
|
||
data.pop("type", None)
|
||
headers = self.headers.dump()
|
||
if headers:
|
||
data["headers"] = headers
|
||
else:
|
||
data.pop("headers", None)
|
||
for k in ("command", "args", "env"):
|
||
data.pop(k, None)
|
||
else: # stdio
|
||
data["command"] = self.command.text().strip()
|
||
args = [ln for ln in self.args.toPlainText().splitlines() if ln.strip() != ""]
|
||
if args:
|
||
data["args"] = args
|
||
else:
|
||
data.pop("args", None)
|
||
env = self.env.dump()
|
||
if env:
|
||
data["env"] = env
|
||
else:
|
||
data.pop("env", None)
|
||
for k in ("url", "type", "headers"):
|
||
data.pop(k, None)
|
||
return data
|
||
|
||
def current_name(self) -> str:
|
||
return self.name.text().strip()
|
||
|
||
def _type_switched(self):
|
||
self.stack.setCurrentIndex(self.type.currentIndex())
|
||
self._emit()
|
||
|
||
def _emit(self):
|
||
if not self._loading and self._on_change:
|
||
self._on_change()
|
||
self.refresh_dependency(auto_open=False)
|
||
self._check_args()
|
||
|
||
# --- 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()
|
||
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()
|
||
|
||
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")
|
||
self.test_btn.setVisible(self.type.currentIndex() == 1)
|
||
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(core.diagnostics_text(self.current_name(), data))
|
||
|
||
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 _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(
|
||
core.diagnostics_text(self.current_name(), self.dump_data())
|
||
)
|
||
|
||
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};")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# 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))
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# Main window
|
||
# --------------------------------------------------------------------------- #
|
||
class MainWindow(QMainWindow):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("Better Claude Config")
|
||
self.resize(940, 640)
|
||
self.setAcceptDrops(True)
|
||
|
||
self.profiles: list[core.Profile] = []
|
||
self.full_config: dict = {}
|
||
self.servers: list[core.ServerEntry] = []
|
||
self.current_profile: core.Profile | 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()
|
||
|
||
# --- 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)
|
||
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.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
|
||
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.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."
|
||
)
|
||
|
||
# --- 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()
|