Compare commits
45 Commits
v1.2.0
..
88e93edc6c
| Author | SHA1 | Date | |
|---|---|---|---|
| 88e93edc6c | |||
| 48904c7787 | |||
| e6b60e94e7 | |||
| 4afe21666d | |||
| 06e74d4d2c | |||
| f92b851127 | |||
| 47c95ac006 | |||
| 6b22ad26f0 | |||
| 874948506c | |||
| ac2e73e9d7 | |||
| 82ff149373 | |||
| 31ef4a0e85 | |||
| 520b1b2ffd | |||
| 9f535fb77f | |||
| 8cf19d43c4 | |||
| 0ef4586698 | |||
| ed7c40cac9 | |||
| 1384ed9703 | |||
| 41891ddad4 | |||
| 408f517c5d | |||
| 9036729cd8 | |||
| 29a08e9532 | |||
| 87303809b8 | |||
| 42456f25d2 | |||
| 2d274b9e03 | |||
| 5c476bb13f | |||
| 62c8a2ea65 | |||
| b485357cd5 | |||
| 6a91f830dc | |||
| 6dacc26057 | |||
| 1087fc84d1 | |||
| 5df364fb2e | |||
| c7b2c90518 | |||
| c493aa0c84 | |||
| bb355dac31 | |||
| d95db2b026 | |||
| 42963f98b4 | |||
| f5c9780948 | |||
| 06326e5e9d | |||
| 6d91c709a7 | |||
| 3b5379a2b8 | |||
| f4d4301c26 | |||
| 5169b7276e | |||
| 668fb903d0 | |||
| 8c456c9a89 |
@@ -29,21 +29,37 @@ jobs:
|
|||||||
run: ruff format --check .
|
run: ruff format --check .
|
||||||
|
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ${{ matrix.os }}
|
||||||
name: Tests (py${{ matrix.python }})
|
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
python: ["3.10", "3.12"]
|
os: [ubuntu-latest]
|
||||||
|
python: ["3.10", "3.12", "3.13"]
|
||||||
|
include:
|
||||||
|
# Windows tests on 3.12 only — the version the release binaries ship
|
||||||
|
# with. The self-hosted Windows runner blocks setup-python's install
|
||||||
|
# script (PowerShell execution policy), so it uses the host's `py`
|
||||||
|
# launcher + venv, same as release.yml.
|
||||||
|
- os: windows-latest
|
||||||
|
python: "3.12"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python }}
|
- name: Set up Python ${{ matrix.python }} (Linux)
|
||||||
|
if: runner.os == 'Linux'
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python }}
|
python-version: ${{ matrix.python }}
|
||||||
|
|
||||||
|
- name: Set up Python venv (Windows)
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
py -${{ matrix.python }} -m venv .venv
|
||||||
|
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
|
||||||
|
|
||||||
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
# bcc_core has no GUI imports, so the test suite needs no PySide6 —
|
||||||
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ from __future__ import annotations
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
QColor,
|
QColor,
|
||||||
|
QCursor,
|
||||||
QDesktopServices,
|
QDesktopServices,
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
QIcon,
|
QIcon,
|
||||||
|
QKeySequence,
|
||||||
QPainter,
|
QPainter,
|
||||||
QPixmap,
|
QPixmap,
|
||||||
)
|
)
|
||||||
@@ -36,6 +39,7 @@ from PySide6.QtWidgets import (
|
|||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
@@ -50,12 +54,17 @@ from PySide6.QtWidgets import (
|
|||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
QTableWidget,
|
QTableWidget,
|
||||||
QTableWidgetItem,
|
QTableWidgetItem,
|
||||||
|
QToolTip,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|
||||||
import bcc_core as core
|
import bcc_core as core
|
||||||
|
|
||||||
|
# A full ~/.claude.json with history can be huge; parsing happens on the UI
|
||||||
|
# thread during drag-and-drop import, so skip anything larger than this.
|
||||||
|
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
|
||||||
|
|
||||||
# --- One-line rebrand: change this to recolor the whole app --------------- #
|
# --- One-line rebrand: change this to recolor the whole app --------------- #
|
||||||
ACCENT = "#f97316" # warm orange
|
ACCENT = "#f97316" # warm orange
|
||||||
ACCENT_DIM = "#c2570b"
|
ACCENT_DIM = "#c2570b"
|
||||||
@@ -72,6 +81,12 @@ WARN = "#fbbf24"
|
|||||||
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
|
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
|
||||||
STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"}
|
STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"}
|
||||||
|
|
||||||
|
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
|
||||||
|
# server tables' "Health" column -- distinct from the PATH-dependency Status
|
||||||
|
# column above.
|
||||||
|
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
|
||||||
|
HEALTH_GLYPH = {"ok": "●", "failed": "●", "untested": "○"}
|
||||||
|
|
||||||
STYLESHEET = f"""
|
STYLESHEET = f"""
|
||||||
/* No font-family here on purpose: Qt already uses the native system UI font
|
/* No font-family here on purpose: Qt already uses the native system UI font
|
||||||
on every platform (San Francisco / Segoe UI / desktop default). Naming
|
on every platform (San Francisco / Segoe UI / desktop default). Naming
|
||||||
@@ -114,6 +129,7 @@ QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
|
|||||||
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
|
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
|
||||||
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||||
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
|
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
|
||||||
|
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
|
||||||
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 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#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; }}
|
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
|
||||||
@@ -166,10 +182,30 @@ class UpdateCheckWorker(QThread):
|
|||||||
running binary. Fails quiet — emits None on any network problem — so
|
running binary. Fails quiet — emits None on any network problem — so
|
||||||
it's safe to fire unattended from a silent startup check as well as from
|
it's safe to fire unattended from a silent startup check as well as from
|
||||||
the About dialog's "Check for updates" button.
|
the About dialog's "Check for updates" button.
|
||||||
|
|
||||||
|
Lifetime: the class keeps every instance alive in `_live` until its
|
||||||
|
thread has finished. Without this, a caller whose own reference dies
|
||||||
|
early (the About dialog is a temporary — closing it mid-check used to
|
||||||
|
GC the dialog and the running QThread with it) crashes the process with
|
||||||
|
"QThread: Destroyed while thread is still running". Callers may drop
|
||||||
|
their reference at any time; signal connections to a destroyed receiver
|
||||||
|
are disconnected by Qt, so a late result is simply discarded.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
done = Signal(object) # dict | None
|
done = Signal(object) # dict | None
|
||||||
|
|
||||||
|
_live: ClassVar[set[UpdateCheckWorker]] = set()
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
UpdateCheckWorker._live.add(self)
|
||||||
|
self.finished.connect(self._release_keepalive)
|
||||||
|
|
||||||
|
def _release_keepalive(self):
|
||||||
|
# Delivered on the main thread after run() has returned; only now is
|
||||||
|
# it safe for the last reference to drop.
|
||||||
|
UpdateCheckWorker._live.discard(self)
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
self.done.emit(core.fetch_latest_release())
|
self.done.emit(core.fetch_latest_release())
|
||||||
|
|
||||||
@@ -239,10 +275,31 @@ class KeyValueTable(QWidget):
|
|||||||
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
|
||||||
self.table.viewport().update()
|
self.table.viewport().update()
|
||||||
|
|
||||||
def _changed(self, *_):
|
def _changed(self, item=None, *_):
|
||||||
|
if item is not None and item.column() == 0:
|
||||||
|
new_key = item.text().strip()
|
||||||
|
row = item.row()
|
||||||
|
if new_key and self._is_duplicate_key(new_key, row):
|
||||||
|
prev_key = item.data(Qt.ItemDataRole.UserRole)
|
||||||
|
prev_key = prev_key if prev_key is not None else ""
|
||||||
|
self.table.blockSignals(True)
|
||||||
|
item.setText(prev_key)
|
||||||
|
self.table.blockSignals(False)
|
||||||
|
QToolTip.showText(QCursor.pos(), f"Duplicate key '{new_key}' — reverted.")
|
||||||
|
return
|
||||||
|
item.setData(Qt.ItemDataRole.UserRole, new_key)
|
||||||
if self._on_change:
|
if self._on_change:
|
||||||
self._on_change()
|
self._on_change()
|
||||||
|
|
||||||
|
def _is_duplicate_key(self, key: str, ignore_row: int) -> bool:
|
||||||
|
for r in range(self.table.rowCount()):
|
||||||
|
if r == ignore_row:
|
||||||
|
continue
|
||||||
|
other = self.table.item(r, 0)
|
||||||
|
if other and other.text().strip() == key:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def _add_row(self):
|
def _add_row(self):
|
||||||
dlg = QDialog(self.window())
|
dlg = QDialog(self.window())
|
||||||
dlg.setWindowTitle(f"Add {self._key_label}")
|
dlg.setWindowTitle(f"Add {self._key_label}")
|
||||||
@@ -269,6 +326,13 @@ class KeyValueTable(QWidget):
|
|||||||
grid.addWidget(val_edit, 1, 1)
|
grid.addWidget(val_edit, 1, 1)
|
||||||
v.addLayout(grid)
|
v.addLayout(grid)
|
||||||
|
|
||||||
|
# Shown when the typed key already exists in the table.
|
||||||
|
dup_warn = QLabel("")
|
||||||
|
dup_warn.setStyleSheet(f"color: {WARN};")
|
||||||
|
dup_warn.setWordWrap(True)
|
||||||
|
dup_warn.hide()
|
||||||
|
v.addWidget(dup_warn)
|
||||||
|
|
||||||
# Type an API_KEY/TOKEN-style name and the value field masks itself.
|
# Type an API_KEY/TOKEN-style name and the value field masks itself.
|
||||||
def _sync_echo(text):
|
def _sync_echo(text):
|
||||||
secret = core.is_secret_key(text)
|
secret = core.is_secret_key(text)
|
||||||
@@ -284,7 +348,15 @@ class KeyValueTable(QWidget):
|
|||||||
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||||
ok_btn.setObjectName("primary")
|
ok_btn.setObjectName("primary")
|
||||||
ok_btn.setEnabled(False)
|
ok_btn.setEnabled(False)
|
||||||
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
|
|
||||||
|
def _validate(text):
|
||||||
|
k = text.strip()
|
||||||
|
dup = bool(k) and self._is_duplicate_key(k, ignore_row=-1)
|
||||||
|
ok_btn.setEnabled(bool(k) and not dup)
|
||||||
|
dup_warn.setText(f"'{k}' already exists" if dup else "")
|
||||||
|
dup_warn.setVisible(dup)
|
||||||
|
|
||||||
|
key_edit.textChanged.connect(_validate)
|
||||||
btns.accepted.connect(dlg.accept)
|
btns.accepted.connect(dlg.accept)
|
||||||
btns.rejected.connect(dlg.reject)
|
btns.rejected.connect(dlg.reject)
|
||||||
v.addWidget(btns)
|
v.addWidget(btns)
|
||||||
@@ -298,13 +370,15 @@ class KeyValueTable(QWidget):
|
|||||||
return
|
return
|
||||||
k = key_edit.text().strip()
|
k = key_edit.text().strip()
|
||||||
val = val_edit.text()
|
val = val_edit.text()
|
||||||
if not k:
|
if not k or self._is_duplicate_key(k, ignore_row=-1):
|
||||||
return
|
return
|
||||||
if self._before_change:
|
if self._before_change:
|
||||||
self._before_change()
|
self._before_change()
|
||||||
r = self.table.rowCount()
|
r = self.table.rowCount()
|
||||||
self.table.insertRow(r)
|
self.table.insertRow(r)
|
||||||
self.table.setItem(r, 0, QTableWidgetItem(k))
|
key_item = QTableWidgetItem(k)
|
||||||
|
key_item.setData(Qt.ItemDataRole.UserRole, k)
|
||||||
|
self.table.setItem(r, 0, key_item)
|
||||||
self.table.setItem(r, 1, QTableWidgetItem(val))
|
self.table.setItem(r, 1, QTableWidgetItem(val))
|
||||||
self._changed()
|
self._changed()
|
||||||
|
|
||||||
@@ -323,7 +397,9 @@ class KeyValueTable(QWidget):
|
|||||||
for k, v in (d or {}).items():
|
for k, v in (d or {}).items():
|
||||||
r = self.table.rowCount()
|
r = self.table.rowCount()
|
||||||
self.table.insertRow(r)
|
self.table.insertRow(r)
|
||||||
self.table.setItem(r, 0, QTableWidgetItem(str(k)))
|
key_item = QTableWidgetItem(str(k))
|
||||||
|
key_item.setData(Qt.ItemDataRole.UserRole, str(k))
|
||||||
|
self.table.setItem(r, 0, key_item)
|
||||||
self.table.setItem(r, 1, QTableWidgetItem(str(v)))
|
self.table.setItem(r, 1, QTableWidgetItem(str(v)))
|
||||||
self.table.blockSignals(False)
|
self.table.blockSignals(False)
|
||||||
|
|
||||||
@@ -1247,6 +1323,31 @@ class LogViewerDialog(QDialog):
|
|||||||
super().closeEvent(event)
|
super().closeEvent(event)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Bundled assets (icons) — resolves both a normal source run and a frozen
|
||||||
|
# PyInstaller build (onefile extracts assets under sys._MEIPASS).
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _asset_dir() -> Path:
|
||||||
|
base = getattr(sys, "_MEIPASS", None)
|
||||||
|
return Path(base) if base else Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _app_icon() -> QIcon:
|
||||||
|
"""Multi-resolution app/window icon from the bundled PNGs (falls back to
|
||||||
|
the .ico). Returns a null QIcon if no asset is found."""
|
||||||
|
icon = QIcon()
|
||||||
|
base = _asset_dir() / "icons" / "twin-gears" / "rounded"
|
||||||
|
for size in (16, 32, 48, 64, 128, 256, 512):
|
||||||
|
f = base / f"icon-{size}.png"
|
||||||
|
if f.is_file():
|
||||||
|
icon.addFile(str(f))
|
||||||
|
if icon.isNull():
|
||||||
|
ico = _asset_dir() / "icons" / "app.ico"
|
||||||
|
if ico.is_file():
|
||||||
|
icon.addFile(str(ico))
|
||||||
|
return icon
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# About dialog
|
# About dialog
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1268,9 +1369,7 @@ class AboutDialog(QDialog):
|
|||||||
self._worker: UpdateCheckWorker | None = None
|
self._worker: UpdateCheckWorker | None = None
|
||||||
self._release_url: str | None = None
|
self._release_url: str | None = None
|
||||||
|
|
||||||
icon_path = (
|
icon_path = _asset_dir() / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
||||||
Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
|
||||||
)
|
|
||||||
if icon_path.is_file():
|
if icon_path.is_file():
|
||||||
self.setWindowIcon(QIcon(str(icon_path)))
|
self.setWindowIcon(QIcon(str(icon_path)))
|
||||||
|
|
||||||
@@ -1394,6 +1493,17 @@ class AboutDialog(QDialog):
|
|||||||
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
|
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
|
||||||
|
# waiting for the old instance to exit, so it must run off the UI thread.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class RestartWorker(QThread):
|
||||||
|
done = Signal(object) # core.RestartResult
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.done.emit(core.restart_claude_desktop())
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Main window
|
# Main window
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1416,6 +1526,12 @@ class MainWindow(QMainWindow):
|
|||||||
self._focused_table = None
|
self._focused_table = None
|
||||||
self._row_of_index: dict[int, tuple] = {}
|
self._row_of_index: dict[int, tuple] = {}
|
||||||
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
|
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
|
||||||
|
self._filter_query = ""
|
||||||
|
self._health: dict[str, tuple[str, str]] = {} # server name -> (HealthStatus, summary)
|
||||||
|
self._test_all_queue: list[core.ServerEntry] = []
|
||||||
|
self._test_all_total = 0
|
||||||
|
self._test_all_done = 0
|
||||||
|
self._health_tester: SpawnTester | None = None
|
||||||
|
|
||||||
central = QWidget()
|
central = QWidget()
|
||||||
self.setCentralWidget(central)
|
self.setCentralWidget(central)
|
||||||
@@ -1425,6 +1541,15 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
root.addLayout(self._build_topbar())
|
root.addLayout(self._build_topbar())
|
||||||
|
|
||||||
|
# Persistent warning banner (MSIX-virtualized config, etc.). Lives in
|
||||||
|
# its own widget because the status bar is rewritten on every action,
|
||||||
|
# which used to wipe the warning before the user could read it.
|
||||||
|
self.warn_banner = QLabel("")
|
||||||
|
self.warn_banner.setObjectName("warnBanner")
|
||||||
|
self.warn_banner.setWordWrap(True)
|
||||||
|
self.warn_banner.hide()
|
||||||
|
root.addWidget(self.warn_banner)
|
||||||
|
|
||||||
# User-draggable divider between the server list and the editor.
|
# User-draggable divider between the server list and the editor.
|
||||||
split = QSplitter(Qt.Orientation.Horizontal)
|
split = QSplitter(Qt.Orientation.Horizontal)
|
||||||
split.setChildrenCollapsible(False)
|
split.setChildrenCollapsible(False)
|
||||||
@@ -1476,14 +1601,16 @@ class MainWindow(QMainWindow):
|
|||||||
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
||||||
if (time.time() - last) < 86400: # at most once/day
|
if (time.time() - last) < 86400: # at most once/day
|
||||||
return
|
return
|
||||||
st.setValue("update/lastCheck", time.time())
|
|
||||||
self._startup_update_worker = UpdateCheckWorker()
|
self._startup_update_worker = UpdateCheckWorker()
|
||||||
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
||||||
self._startup_update_worker.start()
|
self._startup_update_worker.start()
|
||||||
|
|
||||||
def _on_startup_update_checked(self, release: dict | None):
|
def _on_startup_update_checked(self, release: dict | None):
|
||||||
self._startup_update_worker = None
|
self._startup_update_worker = None
|
||||||
if release and core.is_newer_version(core.__version__, release["version"]):
|
if release is None:
|
||||||
|
return # offline/failed check: don't advance lastCheck, allow retry
|
||||||
|
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
|
||||||
|
if core.is_newer_version(core.__version__, release["version"]):
|
||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Update available: {release['version']} · Help ▸ About to view it."
|
f"Update available: {release['version']} · Help ▸ About to view it."
|
||||||
)
|
)
|
||||||
@@ -1533,10 +1660,10 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# --- left (server table) -------------------------------------------- #
|
# --- left (server table) -------------------------------------------- #
|
||||||
def _make_server_table(self, object_name=None):
|
def _make_server_table(self, object_name=None):
|
||||||
t = QTableWidget(0, 4)
|
t = QTableWidget(0, 5)
|
||||||
if object_name:
|
if object_name:
|
||||||
t.setObjectName(object_name)
|
t.setObjectName(object_name)
|
||||||
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"])
|
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"])
|
||||||
t.verticalHeader().setVisible(False)
|
t.verticalHeader().setVisible(False)
|
||||||
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||||
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||||
@@ -1546,6 +1673,7 @@ class MainWindow(QMainWindow):
|
|||||||
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||||
h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
|
h.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
|
||||||
t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl))
|
t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl))
|
||||||
t.itemChanged.connect(self._table_item_changed)
|
t.itemChanged.connect(self._table_item_changed)
|
||||||
return t
|
return t
|
||||||
@@ -1560,6 +1688,46 @@ class MainWindow(QMainWindow):
|
|||||||
head.setObjectName("h1")
|
head.setObjectName("h1")
|
||||||
v.addWidget(head)
|
v.addWidget(head)
|
||||||
|
|
||||||
|
search_row = QHBoxLayout()
|
||||||
|
self.search_box = QLineEdit()
|
||||||
|
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
|
||||||
|
self.search_box.setClearButtonEnabled(True)
|
||||||
|
self.search_box.textChanged.connect(self._on_search_changed)
|
||||||
|
search_row.addWidget(self.search_box, 1)
|
||||||
|
self.enable_all_btn = QPushButton("All on")
|
||||||
|
self.enable_all_btn.setToolTip("Enable every server")
|
||||||
|
self.enable_all_btn.clicked.connect(lambda: self._set_all_enabled(True))
|
||||||
|
search_row.addWidget(self.enable_all_btn)
|
||||||
|
self.disable_all_btn = QPushButton("All off")
|
||||||
|
self.disable_all_btn.setToolTip("Disable every server")
|
||||||
|
self.disable_all_btn.clicked.connect(lambda: self._set_all_enabled(False))
|
||||||
|
search_row.addWidget(self.disable_all_btn)
|
||||||
|
v.addLayout(search_row)
|
||||||
|
|
||||||
|
# Named server sets (issue #52): apply a saved Active/Disabled split
|
||||||
|
# in one click. Sets live in the config file under _bccServerSets.
|
||||||
|
sets_row = QHBoxLayout()
|
||||||
|
sets_lbl = QLabel("Set")
|
||||||
|
sets_lbl.setObjectName("muted")
|
||||||
|
sets_row.addWidget(sets_lbl)
|
||||||
|
self.sets_combo = QComboBox()
|
||||||
|
self.sets_combo.setMinimumWidth(120)
|
||||||
|
sets_row.addWidget(self.sets_combo, 1)
|
||||||
|
self.apply_set_btn = QPushButton("Apply")
|
||||||
|
self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest")
|
||||||
|
self.apply_set_btn.clicked.connect(self._apply_selected_set)
|
||||||
|
sets_row.addWidget(self.apply_set_btn)
|
||||||
|
self.save_set_btn = QPushButton("Save set…")
|
||||||
|
self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set")
|
||||||
|
self.save_set_btn.clicked.connect(self._save_set)
|
||||||
|
sets_row.addWidget(self.save_set_btn)
|
||||||
|
self.del_set_btn = QPushButton("−")
|
||||||
|
self.del_set_btn.setToolTip("Delete the selected set")
|
||||||
|
self.del_set_btn.setMaximumWidth(32)
|
||||||
|
self.del_set_btn.clicked.connect(self._delete_set)
|
||||||
|
sets_row.addWidget(self.del_set_btn)
|
||||||
|
v.addLayout(sets_row)
|
||||||
|
|
||||||
# Active and Disabled sections live in a vertical splitter so the user
|
# Active and Disabled sections live in a vertical splitter so the user
|
||||||
# can drag the divider instead of being stuck with a fixed-height
|
# can drag the divider instead of being stuck with a fixed-height
|
||||||
# disabled list.
|
# disabled list.
|
||||||
@@ -1616,12 +1784,17 @@ class MainWindow(QMainWindow):
|
|||||||
self.undo_btn = QPushButton("Undo")
|
self.undo_btn = QPushButton("Undo")
|
||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
|
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
|
||||||
|
self.test_all_btn = QPushButton("Test all")
|
||||||
|
self.test_all_btn.setToolTip(
|
||||||
|
"Spawn-test every enabled local server, one at a time, and fill in the Health column"
|
||||||
|
)
|
||||||
self.add_btn.clicked.connect(self.add_server)
|
self.add_btn.clicked.connect(self.add_server)
|
||||||
self.dup_btn.clicked.connect(self.duplicate_server)
|
self.dup_btn.clicked.connect(self.duplicate_server)
|
||||||
self.del_btn.clicked.connect(self.delete_server)
|
self.del_btn.clicked.connect(self.delete_server)
|
||||||
self.paste_btn.clicked.connect(self.paste_json)
|
self.paste_btn.clicked.connect(self.paste_json)
|
||||||
self.copy_btn.clicked.connect(self.copy_to_menu)
|
self.copy_btn.clicked.connect(self.copy_to_menu)
|
||||||
self.undo_btn.clicked.connect(self._undo)
|
self.undo_btn.clicked.connect(self._undo)
|
||||||
|
self.test_all_btn.clicked.connect(self._test_all_servers)
|
||||||
for b in (
|
for b in (
|
||||||
self.add_btn,
|
self.add_btn,
|
||||||
self.dup_btn,
|
self.dup_btn,
|
||||||
@@ -1629,6 +1802,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.paste_btn,
|
self.paste_btn,
|
||||||
self.copy_btn,
|
self.copy_btn,
|
||||||
self.undo_btn,
|
self.undo_btn,
|
||||||
|
self.test_all_btn,
|
||||||
):
|
):
|
||||||
bar.addWidget(b)
|
bar.addWidget(b)
|
||||||
# Ctrl+Z shortcut
|
# Ctrl+Z shortcut
|
||||||
@@ -1636,6 +1810,12 @@ class MainWindow(QMainWindow):
|
|||||||
undo_action.setShortcut("Ctrl+Z")
|
undo_action.setShortcut("Ctrl+Z")
|
||||||
undo_action.triggered.connect(self._undo)
|
undo_action.triggered.connect(self._undo)
|
||||||
self.addAction(undo_action)
|
self.addAction(undo_action)
|
||||||
|
# Ctrl+S / Cmd+S shortcut — routed through a guard so it respects
|
||||||
|
# the same dirty/validation gating as the Save button.
|
||||||
|
save_action = QAction(self)
|
||||||
|
save_action.setShortcut(QKeySequence.StandardKey.Save)
|
||||||
|
save_action.triggered.connect(self._save_shortcut)
|
||||||
|
self.addAction(save_action)
|
||||||
bar.addStretch()
|
bar.addStretch()
|
||||||
self.validation_lbl = QLabel("")
|
self.validation_lbl = QLabel("")
|
||||||
bar.addWidget(self.validation_lbl)
|
bar.addWidget(self.validation_lbl)
|
||||||
@@ -1662,6 +1842,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
self.status.setText("Undone.")
|
self.status.setText("Undone.")
|
||||||
|
|
||||||
|
def _set_all_enabled(self, enabled: bool):
|
||||||
|
"""Flip every server's enabled flag in one step (one undo snapshot)."""
|
||||||
|
if not self.servers or all(s.enabled == enabled for s in self.servers):
|
||||||
|
return # nothing to change
|
||||||
|
cur = self._current_index()
|
||||||
|
self._push_undo()
|
||||||
|
for s in self.servers:
|
||||||
|
s.enabled = enabled
|
||||||
|
sel = cur if 0 <= cur < len(self.servers) else None
|
||||||
|
self._refresh_tables(select_index=sel)
|
||||||
|
self._mark_dirty()
|
||||||
|
self.status.setText("All servers enabled." if enabled else "All servers disabled.")
|
||||||
|
|
||||||
# --- profiles -------------------------------------------------------- #
|
# --- profiles -------------------------------------------------------- #
|
||||||
def reload_profiles(self):
|
def reload_profiles(self):
|
||||||
discovered = core.discover_profiles()
|
discovered = core.discover_profiles()
|
||||||
@@ -1679,6 +1872,26 @@ class MainWindow(QMainWindow):
|
|||||||
self.load_profile(self.profiles[0])
|
self.load_profile(self.profiles[0])
|
||||||
else:
|
else:
|
||||||
self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
|
self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
|
||||||
|
self._maybe_warn_msix()
|
||||||
|
|
||||||
|
def _maybe_warn_msix(self):
|
||||||
|
"""
|
||||||
|
Windows-only, no-op everywhere else: if Claude Desktop looks like an
|
||||||
|
MSIX/Store install with a virtualized config, show a persistent banner
|
||||||
|
so edits to the plain %APPDATA% path aren't silently lost. (The status
|
||||||
|
bar is the wrong home for this: it's rewritten on every action.)
|
||||||
|
Defensive on purpose -- this must never block startup or profile load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
warning = core.msix_warning_text()
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if warning:
|
||||||
|
self.warn_banner.setText(f"⚠ {warning}")
|
||||||
|
self.warn_banner.setToolTip(warning)
|
||||||
|
self.warn_banner.show()
|
||||||
|
else:
|
||||||
|
self.warn_banner.hide()
|
||||||
|
|
||||||
def add_custom_config(self):
|
def add_custom_config(self):
|
||||||
start = str(core.app_support_base())
|
start = str(core.app_support_base())
|
||||||
@@ -1733,6 +1946,8 @@ class MainWindow(QMainWindow):
|
|||||||
self.restart_btn.hide()
|
self.restart_btn.hide()
|
||||||
self._undo_stack.clear()
|
self._undo_stack.clear()
|
||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
|
self._health.clear() # health results are per-profile; a fresh load invalidates them
|
||||||
|
self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file
|
||||||
self._refresh_tables(select_index=0 if self.servers else -1)
|
self._refresh_tables(select_index=0 if self.servers else -1)
|
||||||
self._update_status(saved=False)
|
self._update_status(saved=False)
|
||||||
if repaired:
|
if repaired:
|
||||||
@@ -1769,6 +1984,14 @@ class MainWindow(QMainWindow):
|
|||||||
QColor(STATUS_COLORS.get(dep["status"], MUTED))
|
QColor(STATUS_COLORS.get(dep["status"], MUTED))
|
||||||
) # status stays colored even when off
|
) # status stays colored even when off
|
||||||
table.setItem(r, 3, st)
|
table.setItem(r, 3, st)
|
||||||
|
health_status, health_summary = self._health.get(s.name, (core.HealthStatus.UNTESTED, ""))
|
||||||
|
if s.kind == "remote" and s.name not in self._health:
|
||||||
|
health_summary = "remote server — use “Test connection” in the editor"
|
||||||
|
health_item = QTableWidgetItem(HEALTH_GLYPH.get(health_status, "○"))
|
||||||
|
health_item.setForeground(QColor(HEALTH_COLORS.get(health_status, MUTED)))
|
||||||
|
health_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
health_item.setToolTip(health_summary or health_status)
|
||||||
|
table.setItem(r, 4, health_item)
|
||||||
self._row_of_index[master_idx] = (table, r)
|
self._row_of_index[master_idx] = (table, r)
|
||||||
|
|
||||||
def _refresh_tables(self, select_index=None):
|
def _refresh_tables(self, select_index=None):
|
||||||
@@ -1777,8 +2000,16 @@ class MainWindow(QMainWindow):
|
|||||||
self._row_of_index = {}
|
self._row_of_index = {}
|
||||||
self.active_table.setRowCount(0)
|
self.active_table.setRowCount(0)
|
||||||
self.disabled_table.setRowCount(0)
|
self.disabled_table.setRowCount(0)
|
||||||
|
query = self._filter_query
|
||||||
n_active = n_disabled = 0
|
n_active = n_disabled = 0
|
||||||
|
total_active = total_disabled = 0
|
||||||
for i, s in enumerate(self.servers):
|
for i, s in enumerate(self.servers):
|
||||||
|
if s.enabled:
|
||||||
|
total_active += 1
|
||||||
|
else:
|
||||||
|
total_disabled += 1
|
||||||
|
if not core.server_matches_filter(s, query):
|
||||||
|
continue
|
||||||
if s.enabled:
|
if s.enabled:
|
||||||
self._add_row(self.active_table, i, s)
|
self._add_row(self.active_table, i, s)
|
||||||
n_active += 1
|
n_active += 1
|
||||||
@@ -1787,8 +2018,18 @@ class MainWindow(QMainWindow):
|
|||||||
n_disabled += 1
|
n_disabled += 1
|
||||||
self.active_table.setVisible(n_active > 0)
|
self.active_table.setVisible(n_active > 0)
|
||||||
self.active_empty.setVisible(n_active == 0)
|
self.active_empty.setVisible(n_active == 0)
|
||||||
|
self.active_empty.setText(
|
||||||
|
"No matches."
|
||||||
|
if query.strip() and total_active and not n_active
|
||||||
|
else "No active servers. Add one, or Paste JSON."
|
||||||
|
)
|
||||||
self.disabled_table.setVisible(n_disabled > 0)
|
self.disabled_table.setVisible(n_disabled > 0)
|
||||||
self.disabled_empty.setVisible(n_disabled == 0)
|
self.disabled_empty.setVisible(n_disabled == 0)
|
||||||
|
self.disabled_empty.setText(
|
||||||
|
"No matches."
|
||||||
|
if query.strip() and total_disabled and not n_disabled
|
||||||
|
else "Nothing disabled."
|
||||||
|
)
|
||||||
self._refresh_badges()
|
self._refresh_badges()
|
||||||
self._suppress_table = False
|
self._suppress_table = False
|
||||||
self._suppress_sel = False
|
self._suppress_sel = False
|
||||||
@@ -1800,6 +2041,125 @@ class MainWindow(QMainWindow):
|
|||||||
self._load_editor_from_selection()
|
self._load_editor_from_selection()
|
||||||
self._validate()
|
self._validate()
|
||||||
|
|
||||||
|
# --- search / filter -------------------------------------------------- #
|
||||||
|
def _on_search_changed(self, text):
|
||||||
|
self._filter_query = text
|
||||||
|
cur = self._current_index()
|
||||||
|
self._refresh_tables(select_index=cur if cur >= 0 else None)
|
||||||
|
|
||||||
|
# --- named server sets (issue #52) ------------------------------------ #
|
||||||
|
def _refresh_sets_combo(self, select: str | None = None):
|
||||||
|
sets = core.list_server_sets(self.full_config)
|
||||||
|
self.sets_combo.blockSignals(True)
|
||||||
|
self.sets_combo.clear()
|
||||||
|
for name in sorted(sets):
|
||||||
|
self.sets_combo.addItem(name)
|
||||||
|
if select is not None:
|
||||||
|
idx = self.sets_combo.findText(select)
|
||||||
|
if idx >= 0:
|
||||||
|
self.sets_combo.setCurrentIndex(idx)
|
||||||
|
self.sets_combo.blockSignals(False)
|
||||||
|
has_sets = bool(sets)
|
||||||
|
self.apply_set_btn.setEnabled(has_sets)
|
||||||
|
self.del_set_btn.setEnabled(has_sets)
|
||||||
|
|
||||||
|
def _apply_selected_set(self):
|
||||||
|
name = self.sets_combo.currentText()
|
||||||
|
sets = core.list_server_sets(self.full_config)
|
||||||
|
if name not in sets:
|
||||||
|
return
|
||||||
|
self._push_undo()
|
||||||
|
missing = core.apply_server_set(self.servers, sets[name])
|
||||||
|
self._refresh_tables(select_index=self._current_index() if self.servers else None)
|
||||||
|
self._mark_dirty()
|
||||||
|
on = sum(1 for s in self.servers if s.enabled)
|
||||||
|
msg = f"Applied set “{name}” · {on} enabled. Review and Save."
|
||||||
|
if missing:
|
||||||
|
msg += f" ⚠ no longer in this config: {', '.join(missing)}"
|
||||||
|
self.status.setText(msg)
|
||||||
|
|
||||||
|
def _save_set(self):
|
||||||
|
name, ok = QInputDialog.getText(
|
||||||
|
self,
|
||||||
|
"Save server set",
|
||||||
|
"Set name (saves which servers are currently Active):",
|
||||||
|
text=self.sets_combo.currentText(),
|
||||||
|
)
|
||||||
|
name = name.strip()
|
||||||
|
if not ok or not name:
|
||||||
|
return
|
||||||
|
if name in core.list_server_sets(self.full_config) and (
|
||||||
|
QMessageBox.question(self, "Set exists", f"Replace set “{name}”?")
|
||||||
|
!= QMessageBox.StandardButton.Yes
|
||||||
|
):
|
||||||
|
return
|
||||||
|
members = core.save_server_set(self.full_config, name, self.servers)
|
||||||
|
self._refresh_sets_combo(select=name)
|
||||||
|
self._mark_dirty() # the set is written on the next Save
|
||||||
|
self.status.setText(
|
||||||
|
f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _delete_set(self):
|
||||||
|
name = self.sets_combo.currentText()
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
if (
|
||||||
|
QMessageBox.question(self, "Delete set", f"Delete set “{name}”?")
|
||||||
|
!= QMessageBox.StandardButton.Yes
|
||||||
|
):
|
||||||
|
return
|
||||||
|
if core.delete_server_set(self.full_config, name):
|
||||||
|
self._refresh_sets_combo()
|
||||||
|
self._mark_dirty()
|
||||||
|
self.status.setText(f"Set “{name}” deleted. Press Save to write the change.")
|
||||||
|
|
||||||
|
# --- test all (spawn-test every enabled local server) ---------------- #
|
||||||
|
def _test_all_servers(self):
|
||||||
|
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
||||||
|
if not targets:
|
||||||
|
self.status.setText("No enabled local servers to test.")
|
||||||
|
return
|
||||||
|
self._test_all_queue = list(targets)
|
||||||
|
self._test_all_total = len(targets)
|
||||||
|
self._test_all_done = 0
|
||||||
|
self.test_all_btn.setEnabled(False)
|
||||||
|
self.test_all_btn.setText(f"Testing 0/{self._test_all_total}…")
|
||||||
|
self._run_next_health_test()
|
||||||
|
|
||||||
|
def _run_next_health_test(self):
|
||||||
|
if not self._test_all_queue:
|
||||||
|
self.test_all_btn.setEnabled(True)
|
||||||
|
self.test_all_btn.setText("Test all")
|
||||||
|
self.status.setText(f"Tested {self._test_all_done} server(s).")
|
||||||
|
return
|
||||||
|
entry = self._test_all_queue.pop(0)
|
||||||
|
self._health_tester = SpawnTester(dict(entry.data), timeout=3.0)
|
||||||
|
self._health_tester.done.connect(
|
||||||
|
lambda result, name=entry.name: self._on_health_test_done(name, result)
|
||||||
|
)
|
||||||
|
self._health_tester.start()
|
||||||
|
|
||||||
|
def _on_health_test_done(self, name, result):
|
||||||
|
status, summary = core.health_from_spawn_result(result)
|
||||||
|
self._health[name] = (status, summary)
|
||||||
|
self._test_all_done += 1
|
||||||
|
self.test_all_btn.setText(f"Testing {self._test_all_done}/{self._test_all_total}…")
|
||||||
|
self._update_health_cell(name, status, summary)
|
||||||
|
self._run_next_health_test()
|
||||||
|
|
||||||
|
def _update_health_cell(self, name, status, summary):
|
||||||
|
idx = next((i for i, s in enumerate(self.servers) if s.name == name), None)
|
||||||
|
if idx is None or idx not in self._row_of_index:
|
||||||
|
return
|
||||||
|
table, row = self._row_of_index[idx]
|
||||||
|
item = table.item(row, 4)
|
||||||
|
if item is None:
|
||||||
|
return
|
||||||
|
item.setText(HEALTH_GLYPH.get(status, "○"))
|
||||||
|
item.setForeground(QColor(HEALTH_COLORS.get(status, MUTED)))
|
||||||
|
item.setToolTip(summary or status)
|
||||||
|
|
||||||
def _section_html(self, title, n, miss, warn):
|
def _section_html(self, title, n, miss, warn):
|
||||||
base = f"{title} · {n}"
|
base = f"{title} · {n}"
|
||||||
if miss:
|
if miss:
|
||||||
@@ -1902,10 +2262,18 @@ class MainWindow(QMainWindow):
|
|||||||
if not (0 <= idx < len(self.servers)):
|
if not (0 <= idx < len(self.servers)):
|
||||||
return
|
return
|
||||||
entry = self.servers[idx]
|
entry = self.servers[idx]
|
||||||
|
old_name = entry.name
|
||||||
entry.name = self.editor.current_name()
|
entry.name = self.editor.current_name()
|
||||||
entry.data = self.editor.dump_data()
|
entry.data = self.editor.dump_data()
|
||||||
# The server stays in its section (enable state unchanged), so update
|
# The server stays in its section (enable state unchanged), so update
|
||||||
# its existing row in place rather than re-rendering.
|
# its existing row in place rather than re-rendering.
|
||||||
|
# An edit invalidates any cached "Test all" result -- the server that
|
||||||
|
# was spawn-tested no longer matches what's on disk once saved. Pop
|
||||||
|
# both names: the old one (so a rename doesn't leave a stale entry
|
||||||
|
# for whoever takes that name next) and the new one (so we don't
|
||||||
|
# inherit a stale result cached under the name being renamed to).
|
||||||
|
self._health.pop(old_name, None)
|
||||||
|
self._health.pop(entry.name, None)
|
||||||
loc = self._row_of_index.get(idx)
|
loc = self._row_of_index.get(idx)
|
||||||
if loc:
|
if loc:
|
||||||
table, row = loc
|
table, row = loc
|
||||||
@@ -1916,6 +2284,13 @@ class MainWindow(QMainWindow):
|
|||||||
st = table.item(row, 3)
|
st = table.item(row, 3)
|
||||||
st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}")
|
st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}")
|
||||||
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
|
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
|
||||||
|
health_item = table.item(row, 4)
|
||||||
|
if health_item is not None:
|
||||||
|
health_item.setText(HEALTH_GLYPH.get(core.HealthStatus.UNTESTED, "○"))
|
||||||
|
health_item.setForeground(
|
||||||
|
QColor(HEALTH_COLORS.get(core.HealthStatus.UNTESTED, MUTED))
|
||||||
|
)
|
||||||
|
health_item.setToolTip("Not tested since last edit.")
|
||||||
self._suppress_table = False
|
self._suppress_table = False
|
||||||
self._refresh_badges()
|
self._refresh_badges()
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
@@ -2057,11 +2432,23 @@ class MainWindow(QMainWindow):
|
|||||||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||||
self.save_btn.setEnabled(False)
|
self.save_btn.setEnabled(False)
|
||||||
return False
|
return False
|
||||||
|
lint_warnings = core.lint_servers(self.servers)
|
||||||
|
if lint_warnings:
|
||||||
|
self.validation_lbl.setText(f"⚠ {lint_warnings[0]}")
|
||||||
|
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||||
|
else:
|
||||||
self.validation_lbl.setText("✓ valid")
|
self.validation_lbl.setText("✓ valid")
|
||||||
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
||||||
self.save_btn.setEnabled(self.dirty)
|
self.save_btn.setEnabled(self.dirty)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _save_shortcut(self):
|
||||||
|
"""Ctrl+S / Cmd+S handler — only fires when the Save button itself
|
||||||
|
would accept a click, so the shortcut can't bypass validation/dirty
|
||||||
|
gating."""
|
||||||
|
if self.save_btn.isEnabled():
|
||||||
|
self.save()
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
if not self.current_profile:
|
if not self.current_profile:
|
||||||
return
|
return
|
||||||
@@ -2131,18 +2518,30 @@ class MainWindow(QMainWindow):
|
|||||||
def _offer_restart_button(self):
|
def _offer_restart_button(self):
|
||||||
"""Show the 'Restart Claude Desktop' button after a successful save,
|
"""Show the 'Restart Claude Desktop' button after a successful save,
|
||||||
but only when the just-saved profile is Claude Desktop -- restarting
|
but only when the just-saved profile is Claude Desktop -- restarting
|
||||||
makes no sense for Claude Code, which has no GUI process to bounce."""
|
makes no sense for Claude Code, which has no GUI process to bounce --
|
||||||
if self.current_profile and core.profile_targets_claude_desktop(self.current_profile):
|
and only on platforms where Claude Desktop exists (never Linux, where
|
||||||
|
'claude' is the Claude Code CLI)."""
|
||||||
|
if (
|
||||||
|
self.current_profile
|
||||||
|
and core.profile_targets_claude_desktop(self.current_profile)
|
||||||
|
and core.restart_supported()
|
||||||
|
):
|
||||||
self.restart_btn.show()
|
self.restart_btn.show()
|
||||||
else:
|
else:
|
||||||
self.restart_btn.hide()
|
self.restart_btn.hide()
|
||||||
|
|
||||||
def _restart_claude_desktop(self):
|
def _restart_claude_desktop(self):
|
||||||
self.restart_btn.setEnabled(False)
|
self.restart_btn.setEnabled(False)
|
||||||
try:
|
self.restart_btn.setText("Restarting…")
|
||||||
result = core.restart_claude_desktop()
|
# Held on self (MainWindow outlives the worker); replaced only after
|
||||||
finally:
|
# done re-enables the button, so a running thread is never dropped.
|
||||||
|
self._restart_worker = RestartWorker()
|
||||||
|
self._restart_worker.done.connect(self._on_restart_done)
|
||||||
|
self._restart_worker.start()
|
||||||
|
|
||||||
|
def _on_restart_done(self, result):
|
||||||
self.restart_btn.setEnabled(True)
|
self.restart_btn.setEnabled(True)
|
||||||
|
self.restart_btn.setText("Restart Claude Desktop")
|
||||||
self.restart_btn.hide()
|
self.restart_btn.hide()
|
||||||
if result.success:
|
if result.success:
|
||||||
self.status.setText(f"{self.status.text()} · {result.detail}")
|
self.status.setText(f"{self.status.text()} · {result.detail}")
|
||||||
@@ -2203,29 +2602,44 @@ class MainWindow(QMainWindow):
|
|||||||
e.acceptProposedAction()
|
e.acceptProposedAction()
|
||||||
|
|
||||||
def dropEvent(self, e):
|
def dropEvent(self, e):
|
||||||
|
total_added, total_replaced, files_imported = 0, 0, 0
|
||||||
|
undo_pushed = False
|
||||||
for u in e.mimeData().urls():
|
for u in e.mimeData().urls():
|
||||||
path = u.toLocalFile()
|
path = u.toLocalFile()
|
||||||
if not path.endswith(".json"):
|
if not path.endswith(".json"):
|
||||||
continue
|
continue
|
||||||
text = Path(path).read_text(encoding="utf-8")
|
p = Path(path)
|
||||||
|
if p.stat().st_size > MAX_DROP_IMPORT_BYTES:
|
||||||
|
QMessageBox.warning(
|
||||||
|
self,
|
||||||
|
"File too large",
|
||||||
|
f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
try:
|
try:
|
||||||
servers = core.parse_pasted_json(text)
|
servers = core.parse_pasted_json(text)
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}")
|
QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}")
|
||||||
continue
|
continue
|
||||||
|
if not undo_pushed:
|
||||||
self._push_undo()
|
self._push_undo()
|
||||||
|
undo_pushed = True
|
||||||
added, replaced = 0, 0
|
added, replaced = 0, 0
|
||||||
for name, data in servers.items():
|
for name, data in servers.items():
|
||||||
a, r = self._import_server(name, data)
|
a, r = self._import_server(name, data)
|
||||||
added += int(a)
|
added += int(a)
|
||||||
replaced += int(r)
|
replaced += int(r)
|
||||||
|
total_added += added
|
||||||
|
total_replaced += replaced
|
||||||
|
files_imported += 1
|
||||||
|
if files_imported:
|
||||||
self._refresh_tables(select_index=len(self.servers) - 1)
|
self._refresh_tables(select_index=len(self.servers) - 1)
|
||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
self.status.setText(
|
self.status.setText(
|
||||||
f"Imported {added} added, {replaced} replaced from {Path(path).name}. "
|
f"Imported {total_added} added, {total_replaced} replaced from "
|
||||||
"Review and Save."
|
f"{files_imported} file(s). Review and Save."
|
||||||
)
|
)
|
||||||
break
|
|
||||||
|
|
||||||
def closeEvent(self, e):
|
def closeEvent(self, e):
|
||||||
if self.dirty and not self._confirm_discard():
|
if self.dirty and not self._confirm_discard():
|
||||||
@@ -2236,9 +2650,23 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
if sys.platform == "win32":
|
||||||
|
# Without an explicit AppUserModelID, Windows taskbar groups the app
|
||||||
|
# under the default host/Python icon instead of our own window icon.
|
||||||
|
try:
|
||||||
|
import ctypes
|
||||||
|
|
||||||
|
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
|
||||||
|
"io.avezzano.better-claude-config"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
app.setApplicationName("Better Claude Config")
|
app.setApplicationName("Better Claude Config")
|
||||||
app.setApplicationDisplayName("Better Claude Config")
|
app.setApplicationDisplayName("Better Claude Config")
|
||||||
|
icon = _app_icon()
|
||||||
|
if not icon.isNull():
|
||||||
|
app.setWindowIcon(icon)
|
||||||
app.setStyleSheet(STYLESHEET)
|
app.setStyleSheet(STYLESHEET)
|
||||||
win = MainWindow()
|
win = MainWindow()
|
||||||
win.show()
|
win.show()
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ a = Analysis(
|
|||||||
["bcc.py"],
|
["bcc.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[("icons", "icons"), ("data/catalog.json", "data")],
|
||||||
hiddenimports=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.3.0",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.3.0",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+836
-24
@@ -32,6 +32,9 @@ from pathlib import Path
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
CONFIG_FILENAME = "claude_desktop_config.json"
|
CONFIG_FILENAME = "claude_desktop_config.json"
|
||||||
|
|
||||||
# Disabled servers are parked under this non-standard key. Claude Desktop only
|
# Disabled servers are parked under this non-standard key. Claude Desktop only
|
||||||
@@ -39,6 +42,12 @@ CONFIG_FILENAME = "claude_desktop_config.json"
|
|||||||
# we can toggle it back on without losing the definition.
|
# we can toggle it back on without losing the definition.
|
||||||
DISABLED_KEY = "_disabledMcpServers"
|
DISABLED_KEY = "_disabledMcpServers"
|
||||||
|
|
||||||
|
# Named server sets: {set_name: [enabled server names]}. Same pattern as
|
||||||
|
# DISABLED_KEY — a bcc-owned key Claude ignores, stored in the config file so
|
||||||
|
# sets travel with it. Applying a set enables exactly the listed servers and
|
||||||
|
# parks the rest under DISABLED_KEY.
|
||||||
|
SETS_KEY = "_bccServerSets"
|
||||||
|
|
||||||
BACKUP_DIRNAME = ".bcc_backups"
|
BACKUP_DIRNAME = ".bcc_backups"
|
||||||
MAX_BACKUPS = 15
|
MAX_BACKUPS = 15
|
||||||
|
|
||||||
@@ -59,7 +68,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
|||||||
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
||||||
# so it's safe to run unattended, off the UI thread, at startup.
|
# so it's safe to run unattended, off the UI thread, at startup.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
__version__ = "1.2.0"
|
__version__ = "1.3.0"
|
||||||
|
|
||||||
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
||||||
ISSUES_URL = f"{REPO_URL}/issues"
|
ISSUES_URL = f"{REPO_URL}/issues"
|
||||||
@@ -191,17 +200,150 @@ def app_support_base() -> Path:
|
|||||||
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||||
|
|
||||||
|
|
||||||
|
def msix_config_paths(localappdata: str | os.PathLike | None = None) -> list[Path]:
|
||||||
|
"""
|
||||||
|
Find MSIX/Store-packaged Claude Desktop configs.
|
||||||
|
|
||||||
|
When Claude Desktop is installed from the Microsoft Store (MSIX), Windows
|
||||||
|
virtualizes its filesystem writes to a per-package folder under
|
||||||
|
`%LOCALAPPDATA%\\Packages\\<PackageFamilyName>\\LocalCache\\Roaming\\Claude\\`
|
||||||
|
instead of the normal `%APPDATA%\\Claude\\`. A user (or BCC) editing the
|
||||||
|
plain %APPDATA% path can end up changing a file the running app never
|
||||||
|
reads -- see anthropics/claude-code issues #26073, #29100, #38830.
|
||||||
|
|
||||||
|
Globs `<localappdata>/Packages/*Claude*/LocalCache/Roaming/Claude/
|
||||||
|
claude_desktop_config.json` and returns every match that actually exists,
|
||||||
|
sorted for determinism. `localappdata` defaults to the %LOCALAPPDATA% env
|
||||||
|
var (falling back to the usual Windows path) but is accepted as a
|
||||||
|
parameter so this is unit-testable with tmp_path on any platform.
|
||||||
|
|
||||||
|
This function itself is platform-independent (it just globs whatever
|
||||||
|
directory it's given); callers that care about the *current* machine
|
||||||
|
should gate on sys.platform -- see `detect_msix_claude`.
|
||||||
|
"""
|
||||||
|
base = (
|
||||||
|
Path(localappdata)
|
||||||
|
if localappdata is not None
|
||||||
|
else Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
|
||||||
|
)
|
||||||
|
packages = base / "Packages"
|
||||||
|
if not packages.is_dir():
|
||||||
|
return []
|
||||||
|
out: list[Path] = []
|
||||||
|
for pkg_dir in sorted(packages.glob("*Claude*")):
|
||||||
|
cfg = pkg_dir / "LocalCache" / "Roaming" / "Claude" / CONFIG_FILENAME
|
||||||
|
if cfg.is_file():
|
||||||
|
out.append(cfg)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def detect_msix_claude(
|
||||||
|
appdata: str | os.PathLike | None = None,
|
||||||
|
localappdata: str | os.PathLike | None = None,
|
||||||
|
) -> Path | None:
|
||||||
|
"""
|
||||||
|
Best-effort detection of an MSIX-virtualized Claude Desktop install.
|
||||||
|
|
||||||
|
Returns the first virtualized `claude_desktop_config.json` found (see
|
||||||
|
`msix_config_paths`), or None when not running on Windows, no matching
|
||||||
|
package folder exists, or a package folder exists but has no config file
|
||||||
|
written yet. The sys.platform gate makes this a safe no-op to call
|
||||||
|
unconditionally from discovery/diagnostics code on macOS/Linux.
|
||||||
|
|
||||||
|
`appdata`/`localappdata` are threaded through (rather than read straight
|
||||||
|
from os.environ) purely so the whole detection path is unit-testable via
|
||||||
|
tmp_path + monkeypatch without mutating real env vars.
|
||||||
|
"""
|
||||||
|
if not sys.platform.startswith("win"):
|
||||||
|
return None
|
||||||
|
hits = msix_config_paths(localappdata)
|
||||||
|
return hits[0] if hits else None
|
||||||
|
|
||||||
|
|
||||||
|
def msix_warning_text(
|
||||||
|
appdata: str | os.PathLike | None = None,
|
||||||
|
localappdata: str | os.PathLike | None = None,
|
||||||
|
) -> str | None:
|
||||||
|
"""
|
||||||
|
A one-line, paste-safe warning for the diagnostics/status surface when
|
||||||
|
Claude Desktop looks like an MSIX/Store install whose real config lives
|
||||||
|
somewhere other than the plain %APPDATA%\\Claude\\ path. Returns None
|
||||||
|
when nothing was detected (including on non-Windows platforms) or when
|
||||||
|
the virtualized path and the plain path happen to coincide -- i.e. there
|
||||||
|
is nothing surprising to warn about. Contains only filesystem paths, no
|
||||||
|
env values or secrets.
|
||||||
|
"""
|
||||||
|
real = detect_msix_claude(appdata, localappdata)
|
||||||
|
if real is None:
|
||||||
|
return None
|
||||||
|
plain_base = (
|
||||||
|
Path(appdata)
|
||||||
|
if appdata is not None
|
||||||
|
else Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
|
||||||
|
)
|
||||||
|
plain_cfg = plain_base / "Claude" / CONFIG_FILENAME
|
||||||
|
if plain_cfg == real:
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
"Claude Desktop looks like it's installed from the Microsoft Store (MSIX). "
|
||||||
|
f"Windows virtualizes its config, so edits to {plain_cfg} may be silently "
|
||||||
|
f"ignored by the running app. The real config is at: {real}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||||
|
"""
|
||||||
|
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||||
|
|
||||||
|
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||||
|
directory path (that's what the CLI writes as it's used in each repo).
|
||||||
|
Any project whose directory has a `.mcp.json` file next to it -- a
|
||||||
|
standalone file with a top-level `mcpServers` object, same shape BCC
|
||||||
|
already edits -- is surfaced here as its own profile so it can be opened
|
||||||
|
via 'Add config...' without hunting for the path by hand.
|
||||||
|
|
||||||
|
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||||
|
`projects` value that isn't a dict, just yields an empty list rather than
|
||||||
|
raising -- this is best-effort discovery, not a required config load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg = load_config(claude_json_path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
projects = cfg.get("projects")
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
return []
|
||||||
|
out: list[Profile] = []
|
||||||
|
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||||
|
mcp_path = Path(key) / ".mcp.json"
|
||||||
|
if mcp_path.is_file():
|
||||||
|
out.append(
|
||||||
|
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def discover_profiles() -> list[Profile]:
|
def discover_profiles() -> list[Profile]:
|
||||||
"""
|
"""
|
||||||
Find every `Claude*` data directory in the platform's app-support base
|
Find every `Claude*` data directory in the platform's app-support base
|
||||||
(Claude Desktop installs), then also check for a Claude Code global config.
|
(Claude Desktop installs), then also check for a Claude Code global config
|
||||||
|
and any project-scope `.mcp.json` configs it knows about.
|
||||||
|
|
||||||
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
||||||
directory (catches `Claude`, `Claude-Work`, etc.).
|
directory (catches `Claude`, `Claude-Work`, etc.).
|
||||||
|
Windows/MSIX: if Claude Desktop was installed from the Microsoft Store,
|
||||||
|
its real config lives in a virtualized per-package folder rather than the
|
||||||
|
plain %APPDATA%\\Claude\\ path above (see `detect_msix_claude`); when
|
||||||
|
that's detected, it's surfaced here as its own profile so the user can
|
||||||
|
edit the file the app actually reads.
|
||||||
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what
|
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what
|
||||||
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
||||||
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
||||||
for permissions/hooks and rejects an mcpServers key with a schema error.
|
for permissions/hooks and rejects an mcpServers key with a schema error.
|
||||||
|
Project scope: ~/.claude.json also tracks a `projects` map, one entry per
|
||||||
|
directory Claude Code has been run in; any of those with a `.mcp.json`
|
||||||
|
file are surfaced as their own profiles too (see
|
||||||
|
`discover_project_configs`).
|
||||||
"""
|
"""
|
||||||
base = app_support_base()
|
base = app_support_base()
|
||||||
out: list[Profile] = []
|
out: list[Profile] = []
|
||||||
@@ -213,10 +355,22 @@ def discover_profiles() -> list[Profile]:
|
|||||||
cfg = d / CONFIG_FILENAME
|
cfg = d / CONFIG_FILENAME
|
||||||
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
|
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
|
||||||
|
|
||||||
|
msix_cfg = detect_msix_claude()
|
||||||
|
if msix_cfg is not None and str(msix_cfg) not in {str(p.path) for p in out}:
|
||||||
|
out.append(
|
||||||
|
Profile(label="Claude (Microsoft Store / MSIX)", path=msix_cfg, config_exists=True)
|
||||||
|
)
|
||||||
|
|
||||||
home = Path.home()
|
home = Path.home()
|
||||||
cc_cfg = home / ".claude.json"
|
cc_cfg = home / ".claude.json"
|
||||||
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
||||||
|
|
||||||
|
existing_paths = {str(p.path) for p in out}
|
||||||
|
for proj in discover_project_configs(cc_cfg):
|
||||||
|
if str(proj.path) not in existing_paths:
|
||||||
|
existing_paths.add(str(proj.path))
|
||||||
|
out.append(proj)
|
||||||
|
|
||||||
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
||||||
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
||||||
# file only when it actually contains an mcpServers block, so the user can
|
# file only when it actually contains an mcpServers block, so the user can
|
||||||
@@ -307,6 +461,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Named server sets (issue #52)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def list_server_sets(cfg: dict) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Return {set_name: [enabled server names]} from cfg's SETS_KEY.
|
||||||
|
|
||||||
|
Fail-soft: entries whose value isn't a list of strings (hand-edited or
|
||||||
|
corrupted) are skipped rather than raising, so one bad set never hides
|
||||||
|
the rest.
|
||||||
|
"""
|
||||||
|
raw = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for name, members in raw.items():
|
||||||
|
if isinstance(members, list) and all(isinstance(m, str) for m in members):
|
||||||
|
out[str(name)] = list(members)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_server_set(cfg: dict, name: str, servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Snapshot the current enabled-server names into cfg under SETS_KEY as
|
||||||
|
`name` (overwriting an existing set of that name). Returns the saved
|
||||||
|
member list. The caller decides when cfg reaches disk (normal Save flow).
|
||||||
|
"""
|
||||||
|
members = [s.name for s in servers if s.enabled]
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict):
|
||||||
|
sets = {}
|
||||||
|
cfg[SETS_KEY] = sets
|
||||||
|
sets[name] = members
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def delete_server_set(cfg: dict, name: str) -> bool:
|
||||||
|
"""Remove set `name` from cfg. Drops SETS_KEY entirely when the last set
|
||||||
|
goes, so untouched configs don't grow an empty bcc key. Returns True if
|
||||||
|
something was deleted."""
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict) or name not in sets:
|
||||||
|
return False
|
||||||
|
del sets[name]
|
||||||
|
if not sets:
|
||||||
|
cfg.pop(SETS_KEY, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_server_set(servers: list[ServerEntry], enabled_names: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Enable exactly the servers named in `enabled_names`; disable every other
|
||||||
|
entry (in place). Returns the set members that no longer exist in
|
||||||
|
`servers` — the caller surfaces those as a warning, and the rest of the
|
||||||
|
set still applies.
|
||||||
|
"""
|
||||||
|
wanted = set(enabled_names)
|
||||||
|
present: set[str] = set()
|
||||||
|
for s in servers:
|
||||||
|
s.enabled = s.name in wanted
|
||||||
|
present.add(s.name)
|
||||||
|
return sorted(wanted - present)
|
||||||
|
|
||||||
|
|
||||||
def resolve_name_collision(name: str, existing: set[str]) -> str:
|
def resolve_name_collision(name: str, existing: set[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Return a name guaranteed not to collide with `existing`.
|
Return a name guaranteed not to collide with `existing`.
|
||||||
@@ -1111,6 +1329,89 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
|||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def lint_server(name: str, data: dict) -> list[str]:
|
||||||
|
"""Return non-blocking structural warnings for a single server definition.
|
||||||
|
|
||||||
|
Unlike validate_servers, nothing here blocks Save -- these are advisory
|
||||||
|
notes about shapes that will round-trip through JSON fine but are
|
||||||
|
probably not what the user intended (args given as a plain string
|
||||||
|
instead of a list, an env value that isn't a string, an unrecognized
|
||||||
|
`type`, unknown top-level fields, etc.).
|
||||||
|
"""
|
||||||
|
nm = name.strip() or "(unnamed)"
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if "command" in data and not isinstance(data["command"], str):
|
||||||
|
warnings.append(f"'{nm}': 'command' should be a string")
|
||||||
|
|
||||||
|
if "args" in data:
|
||||||
|
args = data["args"]
|
||||||
|
if not isinstance(args, list):
|
||||||
|
warnings.append(f"'{nm}': 'args' should be a list (one argument per item)")
|
||||||
|
elif any(not isinstance(a, str) for a in args):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': 'args' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("env", "headers"):
|
||||||
|
if field not in data:
|
||||||
|
continue
|
||||||
|
val = data[field]
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs")
|
||||||
|
elif any(not isinstance(v, str) for v in val.values()):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': '{field}' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "type" in data:
|
||||||
|
t = data["type"]
|
||||||
|
if t not in ("http", "sse", "stdio"):
|
||||||
|
warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})")
|
||||||
|
|
||||||
|
extra = sorted(k for k in data if k not in KNOWN_FIELDS)
|
||||||
|
if extra:
|
||||||
|
warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def lint_servers(servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""Concatenate lint_server warnings across every entry, in order."""
|
||||||
|
out: list[str] = []
|
||||||
|
for s in servers:
|
||||||
|
out.extend(lint_server(s.name, s.data))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Search / filter
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def server_matches_filter(entry: ServerEntry, query: str) -> bool:
|
||||||
|
"""
|
||||||
|
Case-insensitive substring match against a server's name, and its
|
||||||
|
command (stdio) or url (remote). An empty/whitespace-only query matches
|
||||||
|
everything -- that's what lets the search box double as "no filter".
|
||||||
|
"""
|
||||||
|
q = (query or "").strip().lower()
|
||||||
|
if not q:
|
||||||
|
return True
|
||||||
|
if q in entry.name.lower():
|
||||||
|
return True
|
||||||
|
if entry.kind == "remote":
|
||||||
|
haystack = str(entry.data.get("url", ""))
|
||||||
|
else:
|
||||||
|
haystack = str(entry.data.get("command", ""))
|
||||||
|
return q in haystack.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def filter_servers(entries: list[ServerEntry], query: str) -> list[ServerEntry]:
|
||||||
|
"""Return only the entries that match `query` (see server_matches_filter)."""
|
||||||
|
return [e for e in entries if server_matches_filter(e, query)]
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Dependency / PATH checking
|
# Dependency / PATH checking
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -1453,6 +1754,23 @@ def server_log_path(name: str) -> Path | None:
|
|||||||
_STDERR_CAP = 4096 # bytes
|
_STDERR_CAP = 4096 # bytes
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree_windows(pid: int) -> None:
|
||||||
|
"""
|
||||||
|
Kill `pid` and its whole descendant tree via `taskkill /T /F` (issue #13).
|
||||||
|
|
||||||
|
Popen.kill() only terminates the direct child; runner-style commands
|
||||||
|
(npx → node → server, cmd → real process) leave the actual server alive,
|
||||||
|
leaking a process on every Windows spawn test. taskkill walks the tree.
|
||||||
|
"""
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) # no console flash from the GUI exe
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||||
@@ -1463,12 +1781,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"crashed" — exited with a non-zero code before timeout
|
"crashed" — exited with a non-zero code before timeout
|
||||||
"not_found" — command could not be resolved to an executable
|
"not_found" — command could not be resolved to an executable
|
||||||
"not_applicable" — remote server or no command; nothing to spawn
|
"not_applicable" — remote server or no command; nothing to spawn
|
||||||
|
"error" — unexpected internal failure while spawning/observing
|
||||||
returncode: int | None
|
returncode: int | None
|
||||||
stderr: str (first ~4 KB)
|
stderr: str (first ~4 KB)
|
||||||
detail: str
|
detail: str
|
||||||
|
|
||||||
|
Never raises: the GUI threads (Test launch / Test all) re-enable their
|
||||||
|
buttons only when a result arrives, so an escaping exception would leave
|
||||||
|
the UI stuck. Anything unexpected comes back as outcome "error".
|
||||||
|
|
||||||
Run this off the UI thread — it blocks for up to `timeout` seconds.
|
Run this off the UI thread — it blocks for up to `timeout` seconds.
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
|
return _spawn_test_impl(data, timeout)
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"outcome": "error",
|
||||||
|
"returncode": None,
|
||||||
|
"stderr": "",
|
||||||
|
"detail": f"unexpected error: {e!r}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _spawn_test_impl(data: dict, timeout: float) -> dict:
|
||||||
if "url" in data and "command" not in data:
|
if "url" in data and "command" not in data:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"outcome": "not_applicable",
|
||||||
@@ -1477,7 +1812,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
"detail": "remote server",
|
"detail": "remote server",
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd = (data.get("command") or "").strip()
|
# str() first: pasted JSON can legally carry a non-string here and the
|
||||||
|
# value never round-trips through the editor before a Test all run.
|
||||||
|
cmd = str(data.get("command") or "").strip()
|
||||||
if not cmd:
|
if not cmd:
|
||||||
return {
|
return {
|
||||||
"outcome": "not_applicable",
|
"outcome": "not_applicable",
|
||||||
@@ -1499,7 +1836,8 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
||||||
|
|
||||||
merged_env = {**os.environ, "PATH": augmented_path()}
|
merged_env = {**os.environ, "PATH": augmented_path()}
|
||||||
merged_env.update(data.get("env") or {})
|
# Popen rejects non-string env values; pasted JSON may carry numbers.
|
||||||
|
merged_env.update({str(k): str(v) for k, v in (data.get("env") or {}).items()})
|
||||||
|
|
||||||
stderr_chunks: list[bytes] = []
|
stderr_chunks: list[bytes] = []
|
||||||
|
|
||||||
@@ -1527,6 +1865,10 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
)
|
)
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
||||||
|
else:
|
||||||
|
# The packaged app is windowed (console=False); without this every
|
||||||
|
# spawn test of a console server flashes a console window.
|
||||||
|
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||||
@@ -1548,7 +1890,7 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||||
else:
|
else:
|
||||||
proc.kill() # best-effort on Windows
|
_kill_process_tree_windows(proc.pid)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||||
@@ -1580,6 +1922,53 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Health status (maps a spawn_test() result to a simple tri-state for the
|
||||||
|
# server-list UI's per-row status dot; see "Test all" in bcc.py)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
class HealthStatus:
|
||||||
|
"""
|
||||||
|
Tri-state health for the server-list status dot. A plain class of string
|
||||||
|
constants -- not an Enum -- to match the plain-string status values used
|
||||||
|
elsewhere in this module (see check_dependency's 'status').
|
||||||
|
"""
|
||||||
|
|
||||||
|
UNTESTED = "untested"
|
||||||
|
OK = "ok"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
def health_from_spawn_result(result: dict) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Map a spawn_test() result dict to (HealthStatus, short_summary) for the
|
||||||
|
server-list status column. Reuses spawn_test's own outcome classification
|
||||||
|
rather than re-deriving pass/fail from returncode/stderr:
|
||||||
|
|
||||||
|
outcome "ok" -> OK (server started and kept running)
|
||||||
|
outcome "not_applicable" -> UNTESTED (remote server, or no command set)
|
||||||
|
anything else -> FAILED (exited, crashed, or not found)
|
||||||
|
|
||||||
|
The summary is short enough for a table cell/tooltip; when the process
|
||||||
|
wrote to stderr before dying, its first line is appended for context.
|
||||||
|
"""
|
||||||
|
outcome = result.get("outcome", "")
|
||||||
|
detail = result.get("detail", "") or ""
|
||||||
|
stderr = (result.get("stderr") or "").strip()
|
||||||
|
|
||||||
|
if outcome == "ok":
|
||||||
|
return HealthStatus.OK, detail or "started"
|
||||||
|
if outcome == "not_applicable":
|
||||||
|
return HealthStatus.UNTESTED, detail or "not applicable"
|
||||||
|
|
||||||
|
# exited / crashed / not_found: the server didn't come up cleanly.
|
||||||
|
summary = detail or outcome
|
||||||
|
if stderr:
|
||||||
|
first_line = stderr.splitlines()[0].strip()
|
||||||
|
if first_line:
|
||||||
|
summary = f"{summary} — {first_line}"
|
||||||
|
return HealthStatus.FAILED, summary
|
||||||
|
|
||||||
|
|
||||||
def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]:
|
||||||
"""
|
"""
|
||||||
Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
|
Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
|
||||||
@@ -1662,8 +2051,41 @@ def _run_quiet(cmd: list[str]) -> None:
|
|||||||
subprocess.run(cmd, capture_output=True)
|
subprocess.run(cmd, capture_output=True)
|
||||||
|
|
||||||
|
|
||||||
|
def restart_supported() -> bool:
|
||||||
|
"""
|
||||||
|
True only where restarting Claude Desktop makes sense (macOS, Windows).
|
||||||
|
There is no official Claude Desktop for Linux, and the obvious binary
|
||||||
|
name there ("claude") is the Claude Code CLI — killing or spawning it
|
||||||
|
would be actively harmful. GUI callers gate the Restart button on this.
|
||||||
|
"""
|
||||||
|
return sys.platform == "darwin" or sys.platform.startswith("win")
|
||||||
|
|
||||||
|
|
||||||
|
_MACOS_QUIT_WAIT_S = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _macos_claude_running() -> bool:
|
||||||
|
try:
|
||||||
|
return subprocess.run(["pgrep", "-x", "Claude"], capture_output=True).returncode == 0
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _restart_claude_desktop_macos() -> RestartResult:
|
def _restart_claude_desktop_macos() -> RestartResult:
|
||||||
_run_quiet(["pkill", "-x", "Claude"])
|
_run_quiet(["pkill", "-x", "Claude"])
|
||||||
|
# Wait for the old instance to actually exit: `open -a` against a dying
|
||||||
|
# process can merely re-activate it, and the config is only re-read on a
|
||||||
|
# true relaunch. Blocks up to _MACOS_QUIT_WAIT_S — callers run this off
|
||||||
|
# the UI thread (see RestartWorker in bcc.py).
|
||||||
|
deadline = time.monotonic() + _MACOS_QUIT_WAIT_S
|
||||||
|
while _macos_claude_running():
|
||||||
|
if time.monotonic() > deadline:
|
||||||
|
return RestartResult(
|
||||||
|
False,
|
||||||
|
f"Claude Desktop didn't quit within {_MACOS_QUIT_WAIT_S:.0f}s — "
|
||||||
|
"quit it manually, then reopen it.",
|
||||||
|
)
|
||||||
|
time.sleep(0.15)
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
@@ -1680,8 +2102,18 @@ def _claude_windows_start_menu_shortcut() -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def _restart_claude_desktop_windows() -> RestartResult:
|
def _restart_claude_desktop_windows() -> RestartResult:
|
||||||
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
|
||||||
shortcut = _claude_windows_start_menu_shortcut()
|
shortcut = _claude_windows_start_menu_shortcut()
|
||||||
|
if not shortcut.is_file():
|
||||||
|
# Checked BEFORE killing: an MSIX/Store install has no Start-menu .lnk
|
||||||
|
# at this path, and killing without a relaunch path would leave the
|
||||||
|
# user with no running Claude at all.
|
||||||
|
return RestartResult(
|
||||||
|
False,
|
||||||
|
f"Claude's Start-menu shortcut wasn't found ({shortcut}). "
|
||||||
|
"If Claude Desktop is installed from the Microsoft Store, "
|
||||||
|
"quit and reopen it manually.",
|
||||||
|
)
|
||||||
|
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
||||||
try:
|
try:
|
||||||
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
||||||
# the Start-menu shortcut, and returns immediately.
|
# the Start-menu shortcut, and returns immediately.
|
||||||
@@ -1698,32 +2130,412 @@ def _restart_claude_desktop_windows() -> RestartResult:
|
|||||||
return RestartResult(True, "Claude Desktop restarted.")
|
return RestartResult(True, "Claude Desktop restarted.")
|
||||||
|
|
||||||
|
|
||||||
def _restart_claude_desktop_linux() -> RestartResult:
|
|
||||||
_run_quiet(["pkill", "claude"])
|
|
||||||
try:
|
|
||||||
# The Linux launcher is the app itself (no "open"-style helper), so it
|
|
||||||
# has to be started detached rather than waited on.
|
|
||||||
subprocess.Popen(
|
|
||||||
["claude"],
|
|
||||||
stdin=subprocess.DEVNULL,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
return RestartResult(False, f"Couldn't launch Claude Desktop: {e}")
|
|
||||||
return RestartResult(True, "Claude Desktop restarted.")
|
|
||||||
|
|
||||||
|
|
||||||
def restart_claude_desktop() -> RestartResult:
|
def restart_claude_desktop() -> RestartResult:
|
||||||
"""
|
"""
|
||||||
Kill and relaunch the Claude Desktop app so a freshly saved config takes
|
Kill and relaunch the Claude Desktop app so a freshly saved config takes
|
||||||
effect. The app not currently running is NOT a failure -- pkill/taskkill
|
effect. The app not currently running is NOT a failure -- pkill/taskkill
|
||||||
exiting non-zero just means "nothing to kill", and we go straight to
|
exiting non-zero just means "nothing to kill", and we go straight to
|
||||||
relaunching. Only a failed relaunch is reported as success=False.
|
relaunching. Only a failed relaunch is reported as success=False.
|
||||||
|
|
||||||
|
macOS blocks for up to _MACOS_QUIT_WAIT_S while the old instance exits —
|
||||||
|
run off the UI thread. Unsupported platforms (see restart_supported())
|
||||||
|
refuse without touching any process.
|
||||||
"""
|
"""
|
||||||
if sys.platform == "darwin":
|
if sys.platform == "darwin":
|
||||||
return _restart_claude_desktop_macos()
|
return _restart_claude_desktop_macos()
|
||||||
if sys.platform.startswith("win"):
|
if sys.platform.startswith("win"):
|
||||||
return _restart_claude_desktop_windows()
|
return _restart_claude_desktop_windows()
|
||||||
return _restart_claude_desktop_linux()
|
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# MCP server catalog (issue #10 / #61)
|
||||||
|
#
|
||||||
|
# A curated, SIGNED list of ready-to-use MCP server definitions (bundled with
|
||||||
|
# the app and, later, fetchable/cacheable — see follow-up issues). Every
|
||||||
|
# function here is pure and defensive: catalog bytes may come from a fetch
|
||||||
|
# over the network, a disk cache, or the copy frozen into the binary, and
|
||||||
|
# all three are treated as equally untrusted until their signature verifies.
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# Commands a catalog entry's config is allowed to launch. Anything else
|
||||||
|
# (bash, sh, curl, a raw script interpreter that isn't on this list, ...)
|
||||||
|
# is rejected by validate_catalog() regardless of how plausible it looks.
|
||||||
|
CATALOG_ALLOWED_COMMANDS = frozenset({"npx", "uvx", "docker", "node", "python", "python3"})
|
||||||
|
|
||||||
|
# Ed25519 public keys allowed to sign a catalog, raw 32-byte form. A LIST
|
||||||
|
# (not a single key) so keys can be rotated without bricking installs that
|
||||||
|
# still trust an older key: verify_catalog_signature() accepts a match
|
||||||
|
# against ANY key in this list.
|
||||||
|
CATALOG_PUBKEYS: list[bytes] = [
|
||||||
|
b"\x00" * 32, # TODO: real key from Catalog Console (#62)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Domain-separation prefix for the signed message. The signature covers
|
||||||
|
# this prefix + the raw catalog bytes, never the raw bytes alone, so a
|
||||||
|
# catalog signature can't be replayed against some other byte-for-byte-
|
||||||
|
# identical payload that means something else in a different context.
|
||||||
|
_CATALOG_SIG_DOMAIN = b"bcc-catalog-v1|"
|
||||||
|
|
||||||
|
# Top-level fields that must be https:// URLs when present.
|
||||||
|
_CATALOG_URL_FIELDS = ("homepage", "docs_url", "source")
|
||||||
|
|
||||||
|
# Inline secret-flag=value forms. Distinct from _TOKEN_PREFIXES below --
|
||||||
|
# this catches "--api-key=<real value>" even when the value itself doesn't
|
||||||
|
# match a well-known token prefix.
|
||||||
|
_CATALOG_SECRET_ARG_RE = re.compile(r"(?i)--api[-_]?key=|--token=|--password=")
|
||||||
|
|
||||||
|
# <PLACEHOLDER>-style tokens the GUI must have the user fill in before Save.
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"<[^<>\s]+>")
|
||||||
|
|
||||||
|
# How many versions a single accepted catalog jump may leap in one go. Bounds
|
||||||
|
# a "freeze" attack: a compromised/leaked signing key claiming an absurd
|
||||||
|
# future version would otherwise permanently outrank every legitimate
|
||||||
|
# catalog release from then on, since the resolver always prefers the
|
||||||
|
# highest verified version.
|
||||||
|
_CATALOG_MAX_VERSION_JUMP = 1000
|
||||||
|
|
||||||
|
|
||||||
|
def load_catalog(raw: bytes | str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse catalog bytes/text into a dict using STRICT json.loads ONLY.
|
||||||
|
|
||||||
|
🔴 CRITICAL: the lenient JSON repair pipeline (repair_json_text,
|
||||||
|
parse_pasted_json / parse_pasted_json_verbose) must NEVER be wired in
|
||||||
|
here, or anywhere near catalog handling. That pipeline exists to be
|
||||||
|
forgiving of hand-pasted snippets from docs and blog posts — smart
|
||||||
|
quotes, trailing commas, unquoted keys, whatever a human fat-fingered.
|
||||||
|
Forgiveness is exactly the property a signed payload cannot have:
|
||||||
|
verify_catalog_signature() authenticates the exact bytes that were
|
||||||
|
signed. If what gets displayed/executed is a "repaired" reinterpretation
|
||||||
|
of those bytes rather than the bytes themselves, the signature check
|
||||||
|
still passes while guaranteeing nothing about what actually runs. Always
|
||||||
|
verify raw bytes, then load_catalog() those SAME raw bytes.
|
||||||
|
"""
|
||||||
|
return json.loads(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_version(data: dict) -> int:
|
||||||
|
"""Extract the integer version from a parsed catalog dict (0 if absent/bad)."""
|
||||||
|
version = data.get("version") if isinstance(data, dict) else None
|
||||||
|
return version if isinstance(version, int) and not isinstance(version, bool) else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _secret_looking_arg(a: str) -> bool:
|
||||||
|
"""
|
||||||
|
True when a catalog arg string looks like it embeds a real secret. Reuses
|
||||||
|
the existing token-prefix detector (_is_secret_value / _TOKEN_PREFIXES)
|
||||||
|
rather than reimplementing it — one definition of "looks like a secret"
|
||||||
|
for the whole app.
|
||||||
|
"""
|
||||||
|
if _CATALOG_SECRET_ARG_RE.search(a):
|
||||||
|
return True
|
||||||
|
value = a.split("=", 1)[1] if "=" in a else a
|
||||||
|
return _is_secret_value(value) or _is_secret_value(a)
|
||||||
|
|
||||||
|
|
||||||
|
def _docker_arg_violations(tag: str, args: list[str]) -> list[str]:
|
||||||
|
"""--privileged and volume mounts rooted at / or $HOME are refused."""
|
||||||
|
problems: list[str] = []
|
||||||
|
if "--privileged" in args:
|
||||||
|
problems.append(f"{tag}: config.args uses --privileged, which is not allowed.")
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(args):
|
||||||
|
a = args[i]
|
||||||
|
mount = None
|
||||||
|
if a in ("-v", "--volume") and i + 1 < len(args):
|
||||||
|
mount = args[i + 1]
|
||||||
|
i += 1
|
||||||
|
elif a.startswith("--volume="):
|
||||||
|
mount = a.split("=", 1)[1]
|
||||||
|
elif a.startswith("-v") and a != "-v":
|
||||||
|
mount = a[2:]
|
||||||
|
if mount:
|
||||||
|
source = mount.split(":", 1)[0]
|
||||||
|
if source in ("/", "$HOME") or source.startswith("$HOME"):
|
||||||
|
problems.append(
|
||||||
|
f"{tag}: config.args mounts {source!r}, which is not allowed "
|
||||||
|
"(volume mounts of / or $HOME are refused)."
|
||||||
|
)
|
||||||
|
i += 1
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_catalog_config(tag: str, config) -> list[str]:
|
||||||
|
"""Validate the `config` block of a basic-tier catalog entry."""
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
return [f"{tag}: basic entries require a 'config' object with command+args."]
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
command = config.get("command")
|
||||||
|
if not isinstance(command, str) or not command:
|
||||||
|
problems.append(f"{tag}: config.command must be a non-empty string.")
|
||||||
|
command = ""
|
||||||
|
elif not command.isascii():
|
||||||
|
problems.append(f"{tag}: config.command must be ASCII (non-ASCII code points rejected).")
|
||||||
|
|
||||||
|
if command and command not in CATALOG_ALLOWED_COMMANDS:
|
||||||
|
problems.append(
|
||||||
|
f"{tag}: config.command {command!r} is not on the catalog allowlist "
|
||||||
|
f"({', '.join(sorted(CATALOG_ALLOWED_COMMANDS))})."
|
||||||
|
)
|
||||||
|
|
||||||
|
args = config.get("args")
|
||||||
|
if not isinstance(args, list) or not all(isinstance(a, str) for a in args):
|
||||||
|
problems.append(f"{tag}: config.args must be a list of strings.")
|
||||||
|
args = []
|
||||||
|
|
||||||
|
for a in args:
|
||||||
|
if not a.isascii():
|
||||||
|
problems.append(f"{tag}: config.args contains a non-ASCII value ({a!r}).")
|
||||||
|
if _secret_looking_arg(a):
|
||||||
|
problems.append(
|
||||||
|
f"{tag}: config.args contains a secret-looking value ({a!r}); "
|
||||||
|
"secrets belong in env, never args."
|
||||||
|
)
|
||||||
|
|
||||||
|
if command in ("node", "python", "python3") and any(a in ("-e", "--eval", "-c") for a in args):
|
||||||
|
problems.append(
|
||||||
|
f"{tag}: config.args uses -e/--eval/-c with {command!r}, which is not allowed."
|
||||||
|
)
|
||||||
|
|
||||||
|
if command == "docker":
|
||||||
|
problems.extend(_docker_arg_violations(tag, args))
|
||||||
|
|
||||||
|
env = config.get("env")
|
||||||
|
if env is not None and (
|
||||||
|
not isinstance(env, dict) or any(not isinstance(v, str) for v in env.values())
|
||||||
|
):
|
||||||
|
problems.append(f"{tag}: config.env must be an object of string values.")
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_catalog_entry(idx: int, entry, seen_ids: set[str]) -> list[str]:
|
||||||
|
"""Validate a single `servers[idx]` catalog entry."""
|
||||||
|
tag = f"servers[{idx}]"
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return [f"{tag}: must be an object."]
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
entry_id = entry.get("id")
|
||||||
|
if not isinstance(entry_id, str) or not entry_id.strip():
|
||||||
|
problems.append(f"{tag}: 'id' must be a non-empty string.")
|
||||||
|
else:
|
||||||
|
tag = f"servers[{idx}] ({entry_id!r})"
|
||||||
|
if not entry_id.isascii():
|
||||||
|
problems.append(f"{tag}: 'id' must be ASCII (non-ASCII code points rejected).")
|
||||||
|
if entry_id in seen_ids:
|
||||||
|
problems.append(f"{tag}: duplicate id.")
|
||||||
|
seen_ids.add(entry_id)
|
||||||
|
|
||||||
|
for field in ("display", "description", "category"):
|
||||||
|
if not isinstance(entry.get(field), str) or not entry[field].strip():
|
||||||
|
problems.append(f"{tag}: '{field}' must be a non-empty string.")
|
||||||
|
|
||||||
|
if not isinstance(entry.get("official"), bool):
|
||||||
|
problems.append(f"{tag}: 'official' must be a boolean.")
|
||||||
|
|
||||||
|
setup = entry.get("setup")
|
||||||
|
if setup not in ("basic", "link-only"):
|
||||||
|
problems.append(f"{tag}: 'setup' must be 'basic' or 'link-only'.")
|
||||||
|
|
||||||
|
env_required = entry.get("env_required")
|
||||||
|
if not isinstance(env_required, dict):
|
||||||
|
problems.append(f"{tag}: 'env_required' must be an object.")
|
||||||
|
else:
|
||||||
|
for k, v in env_required.items():
|
||||||
|
if not isinstance(k, str):
|
||||||
|
problems.append(f"{tag}: 'env_required' keys must be strings.")
|
||||||
|
if v != "":
|
||||||
|
problems.append(
|
||||||
|
f"{tag}: env_required[{k!r}] must be an empty string — "
|
||||||
|
"catalog entries never ship secret values, only the names "
|
||||||
|
"of env vars the user must fill in."
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in _CATALOG_URL_FIELDS:
|
||||||
|
if field in entry and entry[field] is not None:
|
||||||
|
url = entry[field]
|
||||||
|
if not isinstance(url, str) or not url.startswith("https://"):
|
||||||
|
problems.append(f"{tag}: '{field}' must be an https:// URL.")
|
||||||
|
|
||||||
|
if setup == "link-only":
|
||||||
|
if entry.get("config") is not None:
|
||||||
|
problems.append(f"{tag}: link-only entries must not have a 'config'.")
|
||||||
|
docs_url = entry.get("docs_url")
|
||||||
|
if not isinstance(docs_url, str) or not docs_url.startswith("https://"):
|
||||||
|
problems.append(f"{tag}: link-only entries require an https:// 'docs_url'.")
|
||||||
|
elif setup == "basic":
|
||||||
|
problems.extend(_validate_catalog_config(tag, entry.get("config")))
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def validate_catalog(data) -> list[str]:
|
||||||
|
"""
|
||||||
|
Validate a parsed catalog dict. Returns a list of human-readable
|
||||||
|
problems; an EMPTY list means the catalog is valid.
|
||||||
|
|
||||||
|
A non-empty list means REJECT THE WHOLE FILE, not just the offending
|
||||||
|
entry. There is no per-entry salvage here: a catalog that is invalid in
|
||||||
|
one place is untrusted everywhere, because a caller that tried to keep
|
||||||
|
"the other 19 entries that looked fine" would need its own judgment call
|
||||||
|
about which parts of a failed-validation file to trust — exactly the
|
||||||
|
judgment call this function exists to make once, centrally.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return ["Catalog root must be a JSON object."]
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
schema = data.get("schema")
|
||||||
|
if not isinstance(schema, int) or isinstance(schema, bool) or schema < 1:
|
||||||
|
problems.append("'schema' must be a positive integer.")
|
||||||
|
|
||||||
|
version = data.get("version")
|
||||||
|
if not isinstance(version, int) or isinstance(version, bool) or version < 1:
|
||||||
|
problems.append("'version' must be a positive integer.")
|
||||||
|
|
||||||
|
servers = data.get("servers")
|
||||||
|
if not isinstance(servers, list):
|
||||||
|
problems.append("'servers' must be a list.")
|
||||||
|
return problems # nothing else to check without a server list
|
||||||
|
|
||||||
|
seen_ids: set[str] = set()
|
||||||
|
for idx, entry in enumerate(servers):
|
||||||
|
problems.extend(_validate_catalog_entry(idx, entry, seen_ids))
|
||||||
|
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def verify_catalog_signature(raw: bytes, sig: bytes, pubkeys: list[bytes]) -> bool:
|
||||||
|
"""
|
||||||
|
Verify an Ed25519 signature over `raw` catalog bytes.
|
||||||
|
|
||||||
|
The signed message is domain-separated: b"bcc-catalog-v1|" + raw, not
|
||||||
|
raw alone (see _CATALOG_SIG_DOMAIN).
|
||||||
|
|
||||||
|
Returns True if ANY key in `pubkeys` verifies — this is what lets keys
|
||||||
|
rotate without bricking installs still trusting an older key.
|
||||||
|
|
||||||
|
Never raises. An invalid signature, a garbage/wrong-length key, a
|
||||||
|
non-bytes argument, an empty signature — all of it just returns False.
|
||||||
|
Signature verification is exactly the wrong place for an exception to
|
||||||
|
accidentally propagate into a code path that fails open.
|
||||||
|
"""
|
||||||
|
if not isinstance(raw, bytes) or not isinstance(sig, (bytes, bytearray)):
|
||||||
|
return False
|
||||||
|
if not sig:
|
||||||
|
return False
|
||||||
|
message = _CATALOG_SIG_DOMAIN + raw
|
||||||
|
for pk in pubkeys or []:
|
||||||
|
try:
|
||||||
|
Ed25519PublicKey.from_public_bytes(bytes(pk)).verify(bytes(sig), message)
|
||||||
|
return True
|
||||||
|
except (InvalidSignature, ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_catalog(
|
||||||
|
bundled: tuple[bytes, bytes] | None,
|
||||||
|
cached: tuple[bytes, bytes] | None,
|
||||||
|
remote: tuple[bytes, bytes] | None,
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Pick the highest-version catalog among bundled/cached/remote. Each
|
||||||
|
argument is either None (unavailable) or an (raw_bytes, signature_bytes)
|
||||||
|
pair.
|
||||||
|
|
||||||
|
🔴 SECURITY: every candidate — including `bundled`, the copy frozen into
|
||||||
|
this binary — is verified against CATALOG_PUBKEYS and re-validated from
|
||||||
|
scratch right here. The bundled catalog gets NO implicit trust. This was
|
||||||
|
a hole in the original design: bundling data/catalog.json as a plain
|
||||||
|
asset would let an unsigned/malformed payload that somehow merged to
|
||||||
|
main ship inside the next release and win the version comparison simply
|
||||||
|
by virtue of being local. Signing (and checking the signature at
|
||||||
|
runtime, every time) closes that.
|
||||||
|
|
||||||
|
Anti-rollback: a candidate's version is never accepted if it's lower
|
||||||
|
than the best verified candidate already found in this same resolution
|
||||||
|
pass — an attacker replaying an old, since-superseded signed catalog
|
||||||
|
can't downgrade you.
|
||||||
|
|
||||||
|
Anti-freeze: a candidate whose version leaps more than
|
||||||
|
_CATALOG_MAX_VERSION_JUMP past the current best is also rejected. A
|
||||||
|
compromised/leaked signing key claiming an absurd future version would
|
||||||
|
otherwise permanently outrank every legitimate release from then on,
|
||||||
|
since the resolver always prefers the highest verified version — this
|
||||||
|
caps how far a single accepted jump can go.
|
||||||
|
|
||||||
|
Returns the winning catalog dict, or {} if nothing verified and
|
||||||
|
validated.
|
||||||
|
"""
|
||||||
|
best: dict = {}
|
||||||
|
best_version = -1
|
||||||
|
|
||||||
|
for candidate in (bundled, cached, remote):
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
|
raw, sig = candidate
|
||||||
|
if not verify_catalog_signature(raw, sig, CATALOG_PUBKEYS):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = load_catalog(raw)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
continue
|
||||||
|
if validate_catalog(data):
|
||||||
|
continue
|
||||||
|
|
||||||
|
version = catalog_version(data)
|
||||||
|
if best_version >= 0:
|
||||||
|
if version < best_version:
|
||||||
|
continue # anti-rollback
|
||||||
|
if version > best_version + _CATALOG_MAX_VERSION_JUMP:
|
||||||
|
continue # anti-freeze
|
||||||
|
|
||||||
|
best = data
|
||||||
|
best_version = version
|
||||||
|
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def catalog_entry_to_paste_json(entry: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Convert a basic-tier catalog entry into the {name: {command, args, env}}
|
||||||
|
shape parse_pasted_json()/_import_server() already understand, so the
|
||||||
|
(future) catalog picker dialog can feed a selection straight into the
|
||||||
|
existing paste-import path instead of growing a parallel one.
|
||||||
|
"""
|
||||||
|
config = entry.get("config") or {}
|
||||||
|
name = entry.get("id") or entry.get("display") or "server"
|
||||||
|
data: dict = {
|
||||||
|
"command": config.get("command", ""),
|
||||||
|
"args": list(config.get("args") or []),
|
||||||
|
}
|
||||||
|
env = config.get("env")
|
||||||
|
if env:
|
||||||
|
data["env"] = dict(env)
|
||||||
|
return {str(name): data}
|
||||||
|
|
||||||
|
|
||||||
|
def config_has_unfilled_placeholders(cfg: dict) -> bool:
|
||||||
|
"""
|
||||||
|
True if any <PLACEHOLDER>-style token remains anywhere in a server
|
||||||
|
config's command/args/env (the shape produced by
|
||||||
|
catalog_entry_to_paste_json). The GUI uses this to refuse Save until
|
||||||
|
every <ALLOWED_DIR>-style token has been filled in with a real value.
|
||||||
|
"""
|
||||||
|
values: list[str] = []
|
||||||
|
cmd = cfg.get("command")
|
||||||
|
if isinstance(cmd, str):
|
||||||
|
values.append(cmd)
|
||||||
|
values.extend(a for a in (cfg.get("args") or []) if isinstance(a, str))
|
||||||
|
env = cfg.get("env") or {}
|
||||||
|
if isinstance(env, dict):
|
||||||
|
values.extend(v for v in env.values() if isinstance(v, str))
|
||||||
|
return any(_PLACEHOLDER_RE.search(v) for v in values)
|
||||||
|
|||||||
@@ -0,0 +1,454 @@
|
|||||||
|
{
|
||||||
|
"schema": 1,
|
||||||
|
"version": 1,
|
||||||
|
"updated": "2026-07-12",
|
||||||
|
"signed_at": "2026-07-12T21:35:19Z",
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"id": "filesystem",
|
||||||
|
"display": "Filesystem",
|
||||||
|
"description": "Lets Claude read, write, search, and edit files in directories you explicitly allow.",
|
||||||
|
"category": "files",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-filesystem@2026.7.10",
|
||||||
|
"<ALLOWED_DIR>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<ALLOWED_DIR>": "Absolute path to a directory the server may read/write. Add more directories as additional args."
|
||||||
|
},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/filesystem",
|
||||||
|
"notes": "Part of the official modelcontextprotocol/servers reference monorepo (star count is for the whole repo). Clients that support MCP 'roots' can also grant directories dynamically instead of via args.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "fetch",
|
||||||
|
"display": "Fetch",
|
||||||
|
"description": "Fetches a URL and converts the page to clean markdown so Claude can read web content.",
|
||||||
|
"category": "dev",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-server-fetch@2026.7.10"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/fetch",
|
||||||
|
"notes": "Can access local/internal IPs, so treat as a mild security risk on untrusted networks. Add '--ignore-robots-txt' or '--user-agent=...' as extra args if needed.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "memory",
|
||||||
|
"display": "Memory",
|
||||||
|
"description": "Gives Claude a persistent knowledge-graph memory that survives across conversations.",
|
||||||
|
"category": "ai",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-memory@2026.7.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/memory",
|
||||||
|
"notes": "Stores entities/relations as a local JSON knowledge graph (path configurable via MEMORY_FILE_PATH env var).",
|
||||||
|
"last_release": "2026-07-04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "sequential-thinking",
|
||||||
|
"display": "Sequential Thinking",
|
||||||
|
"description": "Gives Claude a structured, step-by-step reasoning tool for breaking down complex problems.",
|
||||||
|
"category": "ai",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@modelcontextprotocol/server-sequential-thinking@2026.7.4"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/sequentialthinking",
|
||||||
|
"notes": "Set DISABLE_THOUGHT_LOGGING=true to stop it from printing each thought step to the console.",
|
||||||
|
"last_release": "2026-07-04"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "git",
|
||||||
|
"display": "Git",
|
||||||
|
"description": "Lets Claude read history, diff, branch, and search a local git repository.",
|
||||||
|
"category": "dev",
|
||||||
|
"homepage": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||||
|
"stars": 85995,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-server-git@2026.7.10",
|
||||||
|
"--repository",
|
||||||
|
"<REPO_PATH>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<REPO_PATH>": "Absolute path to the local git repository"
|
||||||
|
},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/modelcontextprotocol/servers/tree/main/src/git",
|
||||||
|
"notes": "Operates on a local checkout only; it does not talk to GitHub/GitLab APIs (pair with the GitHub or GitLab server for that).",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "github",
|
||||||
|
"display": "GitHub",
|
||||||
|
"description": "Lets Claude read repos/code, and manage issues, pull requests, and Actions on GitHub.",
|
||||||
|
"category": "code-hosting",
|
||||||
|
"homepage": "https://github.com/github/github-mcp-server",
|
||||||
|
"stars": 30202,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
|
"ghcr.io/github/github-mcp-server:v1.0.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"GITHUB_PERSONAL_ACCESS_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/github/github-mcp-server/blob/main/docs/installation-guides/README.md",
|
||||||
|
"notes": "The old '@modelcontextprotocol/server-github' npm package is discontinued (deprecated April 2025). GitHub now ships a Docker-based local server (requires Docker installed/running) plus a hosted remote server at https://api.githubcopilot.com/mcp/ that supports OAuth or PAT auth without Docker."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "playwright",
|
||||||
|
"display": "Playwright",
|
||||||
|
"description": "Lets Claude drive a real browser (click, type, navigate, screenshot) using Playwright's accessibility-tree snapshots.",
|
||||||
|
"category": "browser",
|
||||||
|
"homepage": "https://github.com/microsoft/playwright-mcp",
|
||||||
|
"stars": 34000,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"@playwright/mcp@0.0.78"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/microsoft/playwright-mcp#readme",
|
||||||
|
"notes": "Maintained by the Playwright team at Microsoft. Add '--isolated' for a throwaway profile, or '--browser firefox|webkit|msedge' to change engine. A persistent browser profile is used by default so logins carry over between sessions.",
|
||||||
|
"last_release": "2026-07-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chrome-devtools",
|
||||||
|
"display": "Chrome DevTools",
|
||||||
|
"description": "Lets Claude control Chrome and use real DevTools features: performance traces, network inspection, console logs, screenshots.",
|
||||||
|
"category": "browser",
|
||||||
|
"homepage": "https://github.com/ChromeDevTools/chrome-devtools-mcp",
|
||||||
|
"stars": 45000,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"chrome-devtools-mcp@1.5.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/ChromeDevTools/chrome-devtools-mcp#readme",
|
||||||
|
"notes": "Maintained by the Google Chrome DevTools team; only officially supports Google Chrome / Chrome for Testing. Exposes the browser's content to the MCP client, so avoid sensitive sites while connected. Add '--slim --headless' for a minimal 3-tool basic-automation mode.",
|
||||||
|
"last_release": "2026-07-03"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "postgres",
|
||||||
|
"display": "Postgres MCP Pro",
|
||||||
|
"description": "Lets Claude query, inspect schema, and analyze/tune performance of a PostgreSQL database.",
|
||||||
|
"category": "database",
|
||||||
|
"homepage": "https://github.com/crystaldba/postgres-mcp",
|
||||||
|
"stars": 2400,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"postgres-mcp@0.3.0",
|
||||||
|
"--access-mode=restricted"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"DATABASE_URI": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/crystaldba/postgres-mcp#readme",
|
||||||
|
"notes": "The official reference Postgres server was archived by the MCP team; this community server (Crystal DBA) is the most capable/most-referenced replacement, adding index tuning and EXPLAIN-plan analysis. Use --access-mode=restricted for read-only/production use. Docker image also available (crystaldba/postgres-mcp). Catalog ships --access-mode=restricted (read-only); switch to unrestricted yourself if you want writes.",
|
||||||
|
"last_release": "2025-05-16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "n8n",
|
||||||
|
"display": "n8n",
|
||||||
|
"description": "Build, validate, and deploy n8n workflows with full node documentation for the AI.",
|
||||||
|
"category": "infra",
|
||||||
|
"homepage": "https://github.com/czlonkowski/n8n-mcp",
|
||||||
|
"stars": 22257,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"n8n-mcp@2.63.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"MCP_MODE": "",
|
||||||
|
"N8N_API_URL": "",
|
||||||
|
"N8N_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/czlonkowski/n8n-mcp",
|
||||||
|
"notes": "Set MCP_MODE=stdio (required for Claude Desktop, prevents debug logs from breaking the protocol). N8N_API_URL/N8N_API_KEY are optional — without them you still get full node documentation, validation, and template search; with them you get live workflow create/update/execute against your own n8n instance. A hosted free-tier alternative exists at dashboard.n8n-mcp.com.",
|
||||||
|
"last_release": "2026-07-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "notion",
|
||||||
|
"display": "Notion",
|
||||||
|
"description": "Read, search, and edit Notion pages, databases, and comments from your AI assistant.",
|
||||||
|
"category": "productivity",
|
||||||
|
"homepage": "https://github.com/makenotion/notion-mcp-server",
|
||||||
|
"stars": 4400,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@notionhq/notion-mcp-server@2.4.1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"NOTION_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://developers.notion.com/docs/mcp",
|
||||||
|
"notes": "Notion is prioritizing its hosted remote MCP (OAuth, https://mcp.notion.com/mcp) and may eventually sunset this local package, but the stdio server still works today and is the simplest way to get a static config with an internal-integration token.",
|
||||||
|
"last_release": "2026-06-22"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "obsidian",
|
||||||
|
"display": "Obsidian",
|
||||||
|
"description": "Read, search, and edit notes in your Obsidian vault.",
|
||||||
|
"category": "personal",
|
||||||
|
"homepage": "https://github.com/MarkusPfundstein/mcp-obsidian",
|
||||||
|
"stars": 4067,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-obsidian@0.2.2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"OBSIDIAN_API_KEY": "",
|
||||||
|
"OBSIDIAN_HOST": "",
|
||||||
|
"OBSIDIAN_PORT": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/MarkusPfundstein/mcp-obsidian",
|
||||||
|
"notes": "Requires the Obsidian Local REST API community plugin installed and enabled in Obsidian; copy the API key from the plugin settings. OBSIDIAN_HOST defaults to 127.0.0.1 and OBSIDIAN_PORT to 27124 if omitted.",
|
||||||
|
"last_release": "2025-04-01"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "brave-search",
|
||||||
|
"display": "Brave Search",
|
||||||
|
"description": "Search the web, news, images, and videos using Brave's independent search index.",
|
||||||
|
"category": "search",
|
||||||
|
"homepage": "https://github.com/brave/brave-search-mcp-server",
|
||||||
|
"stars": 1288,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@brave/brave-search-mcp-server@2.0.85",
|
||||||
|
"--transport",
|
||||||
|
"stdio"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"BRAVE_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/brave/brave-search-mcp-server",
|
||||||
|
"notes": "Official Brave server; replaced the old archived modelcontextprotocol/servers brave-search entry (now in modelcontextprotocol/servers-archived). Get an API key from the Brave Search API dashboard.",
|
||||||
|
"last_release": "2026-06-15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tavily",
|
||||||
|
"display": "Tavily",
|
||||||
|
"description": "AI-optimized web search, extract, map, and crawl API built for LLM agents.",
|
||||||
|
"category": "search",
|
||||||
|
"homepage": "https://github.com/tavily-ai/tavily-mcp",
|
||||||
|
"stars": 2206,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"tavily-mcp@0.2.21"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"TAVILY_API_KEY": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/tavily-ai/tavily-mcp",
|
||||||
|
"notes": "Tavily also offers a hosted remote MCP endpoint (mcp.tavily.com) with OAuth as an alternative to running the local npx server.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-assistant",
|
||||||
|
"display": "Home Assistant",
|
||||||
|
"description": "Control smart home devices, query states, and troubleshoot automations in Home Assistant.",
|
||||||
|
"category": "smart-home",
|
||||||
|
"homepage": "https://github.com/voska/hass-mcp",
|
||||||
|
"stars": 308,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"HA_URL",
|
||||||
|
"-e",
|
||||||
|
"HA_TOKEN",
|
||||||
|
"voska/hass-mcp:0.5.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {
|
||||||
|
"HA_URL": "",
|
||||||
|
"HA_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://github.com/voska/hass-mcp",
|
||||||
|
"notes": "HA_URL example: http://homeassistant.local:8123 (use http://host.docker.internal:8123 if HA runs in Docker on the same machine). HA_TOKEN is a Home Assistant long-lived access token from your profile page. A more actively developed alternative is the community 'HA-MCP' integration (homeassistant-ai/ha-mcp, ~3.9k stars), but it installs inside Home Assistant itself via HACS rather than as an external stdio process, so it doesn't fit this catalog's launch-line format."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kubernetes",
|
||||||
|
"display": "Kubernetes",
|
||||||
|
"description": "Lets Claude inspect and manage Kubernetes/OpenShift resources — pods, deployments, logs, Helm releases — using your local kubeconfig.",
|
||||||
|
"category": "infra",
|
||||||
|
"homepage": "https://github.com/containers/kubernetes-mcp-server",
|
||||||
|
"stars": 1626,
|
||||||
|
"official": false,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"kubernetes-mcp-server@0.0.64"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://github.com/containers/kubernetes-mcp-server#readme",
|
||||||
|
"notes": "Not an official Kubernetes SIG project, but a Go-native (no kubectl dependency) implementation maintained under the 'containers' GitHub org (Podman/Red Hat-adjacent) that's widely regarded as the most capable K8s MCP server, supporting Kubernetes and OpenShift. Uses your existing ~/.kube/config automatically; add --read-only to prevent writes.",
|
||||||
|
"last_release": "2026-07-10"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "aws-api-mcp-server",
|
||||||
|
"display": "AWS API MCP Server (AWS Labs)",
|
||||||
|
"description": "Lets your AI assistant run AWS CLI commands to inspect and manage AWS resources across virtually every AWS service.",
|
||||||
|
"category": "cloud",
|
||||||
|
"homepage": "https://github.com/awslabs/mcp",
|
||||||
|
"stars": 9431,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"awslabs.aws-api-mcp-server@1.3.46"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"placeholders": {},
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://awslabs.github.io/mcp/servers/aws-api-mcp-server",
|
||||||
|
"notes": "AWS credentials are NOT set in this MCP config — configure them beforehand via `aws configure` (or set AWS_API_MCP_PROFILE_NAME to pick a named profile) so boto3's standard credential chain can find them. Optional env vars: AWS_REGION (default us-east-1), READ_OPERATIONS_ONLY=true to block all mutating AWS calls. AWS notes this server is being superseded by a newer unified AWS MCP server referenced in their agent-toolkit docs.",
|
||||||
|
"last_release": "2026-06-25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "grafana",
|
||||||
|
"display": "Grafana",
|
||||||
|
"description": "Query dashboards, datasources, alerts and incidents in Grafana from your AI assistant.",
|
||||||
|
"category": "observability",
|
||||||
|
"homepage": "https://github.com/grafana/mcp-grafana",
|
||||||
|
"stars": 3227,
|
||||||
|
"official": true,
|
||||||
|
"setup": "basic",
|
||||||
|
"config": {
|
||||||
|
"command": "uvx",
|
||||||
|
"args": [
|
||||||
|
"mcp-grafana@0.17.1"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"GRAFANA_URL": "<GRAFANA_URL>"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"<GRAFANA_URL>": "Your Grafana instance URL, e.g. http://localhost:3000 or https://yourstack.grafana.net"
|
||||||
|
},
|
||||||
|
"env_required": {
|
||||||
|
"GRAFANA_SERVICE_ACCOUNT_TOKEN": ""
|
||||||
|
},
|
||||||
|
"docs_url": "https://grafana.com/docs/grafana/latest/developer-resources/mcp/",
|
||||||
|
"notes": "Requires Grafana 9.0+ for full functionality — datasource-related tools may not work correctly on older versions.",
|
||||||
|
"last_release": "2026-07-07"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "slack",
|
||||||
|
"display": "Slack",
|
||||||
|
"description": "Search Slack messages, files, and members, and send messages or manage channels from your AI assistant.",
|
||||||
|
"category": "communication",
|
||||||
|
"homepage": "https://slack.com/help/articles/48855576908307-Guide-to-the-Slack-MCP-server",
|
||||||
|
"stars": null,
|
||||||
|
"official": true,
|
||||||
|
"setup": "link-only",
|
||||||
|
"env_required": {},
|
||||||
|
"docs_url": "https://docs.slack.dev/changelog/2026/02/17/slack-mcp/",
|
||||||
|
"notes": "Slack's own MCP server went GA Feb 17, 2026 (streamable HTTP at https://mcp.slack.com/mcp, OAuth). No stdio one-liner is published because it's a hosted, permissioned connector. A well-known community alternative, korotovsky/slack-mcp-server (~1.6k GitHub stars, MIT, not an official Slack product), supports stdio/SSE/HTTP with bot or browser-session tokens and no app-install requirement if a stdio option is preferred."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+2
-1
@@ -1,12 +1,13 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.2.0"
|
version = "1.3.0"
|
||||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PySide6>=6.6",
|
"PySide6>=6.6",
|
||||||
|
"cryptography>=42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+1052
-44
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user