Files
better-claude-config/bcc.py
T
AJ e98cb7abd7
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s
feat: stdio server spawn-test (issue #2)
Add spawn_test() to bcc_core — spawns a stdio server for up to 3 s,
captures stderr, and reports ok/exited/crashed/not_found. Key design
decisions driven by real MCP server behaviour:

- stdin=PIPE (never written): servers block on JSON-RPC input and stay
  alive, so "still running after timeout" reliably signals a healthy
  start. stdin=DEVNULL would send EOF, causing well-behaved servers to
  exit 0 and be misclassified as "exited".
- Command resolved via shutil.which(augmented_path()) before Popen so
  subprocess PATH resolution is unambiguous across platforms.
- start_new_session=True on POSIX + os.killpg on timeout: kills the
  whole process group, not just the launcher (npx, uvx), which would
  otherwise orphan the actual node/python grandchild process.
- stdout=DEVNULL: draining a PIPE we don't read would deadlock at ~64 KB.
- stderr drained in a daemon thread, capped at 4 KB.

GUI: SpawnTester(QThread) wraps spawn_test; "Test launch" button in
ServerEditor dep row (stdio only, visible when command resolves ok/warn).
Result colours match the existing dep-status palette (green/amber/red).
Stderr appended to the diagnostics panel if it is open.

7 new unit tests cover all outcomes and the stdin-open regression guard.
Ran python bcc.py locally: button appears for stdio servers whose command
resolves, is hidden for remote servers and missing-command servers.

Closes #2

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 00:57:46 -04:00

1611 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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,
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)
# --------------------------------------------------------------------------- #
# 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
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)
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")
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(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};")
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")
outcome = result.get("outcome", "")
detail = result.get("detail", "")
stderr = result.get("stderr", "").strip()
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};")
if stderr and self.diag_card.isVisible():
current = self.diag_text.toPlainText()
self.diag_text.setPlainText(current + f"\n\n── stderr from Test launch ──\n{stderr}")
# --------------------------------------------------------------------------- #
# 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}&nbsp;&nbsp;&nbsp;<span style="color:{BAD};">▲ {miss} broken</span>'
if warn:
return f'{base}&nbsp;&nbsp;&nbsp;<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()