Files
better-claude-config/bcc.py
T
2026-06-29 14:12:50 -04:00

1084 lines
44 KiB
Python
Raw Permalink 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 Qt, QSize, QTimer, Signal, QThread
from PySide6.QtGui import QAction, QColor, QFont, QGuiApplication
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout,
QLabel, QPushButton, QComboBox, QLineEdit, QPlainTextEdit, QTableWidget,
QTableWidgetItem, QHeaderView, QStackedWidget, QFileDialog, QMessageBox,
QDialog, QDialogButtonBox, QAbstractItemView, QInputDialog, QFrame,
QSizePolicy, QMenu, QStyle,
)
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"""
* {{ font-family: -apple-system, "Segoe UI", "Inter", "DejaVu Sans", sans-serif; 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};
}}
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; }}
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: "SF Mono", "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; }}
"""
# --------------------------------------------------------------------------- #
# 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):
super().__init__()
self._on_change = on_change
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)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setMaximumHeight(150)
self.table.itemChanged.connect(self._changed)
lay.addWidget(self.table)
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):
r = self.table.rowCount()
self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(""))
self.table.setItem(r, 1, QTableWidgetItem(""))
self._changed()
def _remove_row(self):
r = self.table.currentRow()
if r >= 0:
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):
super().__init__()
self.setObjectName("card")
self._on_change = on_change
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)
# 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)
outer.addStretch()
self.setEnabled(False)
def _lbl(self, t):
l = QLabel(t)
l.setObjectName("muted")
return l
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)
v.addWidget(self._lbl("Arguments (one per line)"))
self.args = QPlainTextEdit()
self.args.setPlaceholderText("-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents")
self.args.setMaximumHeight(120)
self.args.textChanged.connect(self._emit)
v.addWidget(self.args)
v.addWidget(self._lbl("Environment variables"))
self.env = KeyValueTable("Variable", "Value", on_change=self._emit)
v.addWidget(self.env)
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)
v.addWidget(self.headers)
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._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)
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)
# --- 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};")
# --------------------------------------------------------------------------- #
# Paste-JSON dialog
# --------------------------------------------------------------------------- #
class PasteDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Paste MCP JSON")
self.resize(560, 380)
self.result_servers: dict[str, dict] | None = None
v = QVBoxLayout(self)
info = QLabel(
"Paste a config snippet from any MCP doc. Accepts a full "
"{\"mcpServers\": {…}} block, just the inner {\"name\": {…}} map, "
"or a single bare server object."
)
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.setStyleSheet(f"color: {BAD};")
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")
btns.addButton(QDialogButtonBox.StandardButton.Cancel)
btns.accepted.connect(self._try_parse)
btns.rejected.connect(self.reject)
v.addWidget(btns)
def _try_parse(self):
try:
self.result_servers = core.parse_pasted_json(self.box.toPlainText())
self.accept()
except Exception as e:
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] = {}
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(14, 14, 14, 10)
root.setSpacing(12)
root.addLayout(self._build_topbar())
split = QHBoxLayout()
split.setSpacing(12)
split.addWidget(self._build_left(), 3)
self.editor = ServerEditor(on_change=self._editor_changed)
split.addWidget(self.editor, 4)
root.addLayout(split, 1)
root.addLayout(self._build_actionbar())
self.status = QLabel("Ready.")
self.status.setObjectName("statusbar")
root.addWidget(self.status)
self.reload_profiles()
# --- 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)
self.active_label = QLabel("Active")
self.active_label.setObjectName("section")
v.addWidget(self.active_label)
self.active_table = self._make_server_table()
v.addWidget(self.active_table, 3)
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()
v.addWidget(self.active_empty)
self.disabled_label = QLabel("⊘ Disabled")
self.disabled_label.setObjectName("sectionDisabled")
v.addWidget(self.disabled_label)
self.disabled_table = self._make_server_table(object_name="disabledTable")
self.disabled_table.setMaximumHeight(170)
v.addWidget(self.disabled_table, 1)
self.disabled_empty = QLabel("Nothing disabled.")
self.disabled_empty.setObjectName("placeholder")
self.disabled_empty.setAlignment(Qt.AlignmentFlag.AlignCenter)
v.addWidget(self.disabled_empty)
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.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)
for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, self.copy_btn):
bar.addWidget(b)
bar.addStretch()
self.validation_lbl = QLabel("")
bar.addWidget(self.validation_lbl)
return bar
# --- profiles -------------------------------------------------------- #
def reload_profiles(self):
discovered = core.discover_profiles()
existing_paths = {str(p.path) for p in self.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
try:
self.full_config = core.load_config(profile.path)
except Exception as e:
QMessageBox.critical(
self, "Could not read config",
f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install."
)
return
self.current_profile = profile
self.servers = core.extract_servers(self.full_config)
self.dirty = False
self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False)
# --- 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.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):
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
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
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
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
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
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()