Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 .
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Tests (py${{ matrix.python }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
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:
|
||||
- name: Checkout
|
||||
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
|
||||
with:
|
||||
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 —
|
||||
# keeps CI fast and avoids Qt system-library headaches on the runner.
|
||||
- name: Install test dependencies
|
||||
|
||||
@@ -13,14 +13,17 @@ from __future__ import annotations
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QCursor,
|
||||
QDesktopServices,
|
||||
QGuiApplication,
|
||||
QIcon,
|
||||
QKeySequence,
|
||||
QPainter,
|
||||
QPixmap,
|
||||
)
|
||||
@@ -36,6 +39,7 @@ from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
@@ -50,12 +54,17 @@ from PySide6.QtWidgets import (
|
||||
QStyledItemDelegate,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolTip,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
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 --------------- #
|
||||
ACCENT = "#f97316" # warm orange
|
||||
ACCENT_DIM = "#c2570b"
|
||||
@@ -72,6 +81,12 @@ WARN = "#fbbf24"
|
||||
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
|
||||
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"""
|
||||
/* No font-family here on purpose: Qt already uses the native system UI font
|
||||
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::add-line, QScrollBar::sub-line {{ height: 0; }}
|
||||
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#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; }}
|
||||
@@ -166,10 +182,30 @@ class UpdateCheckWorker(QThread):
|
||||
running binary. Fails quiet — emits None on any network problem — so
|
||||
it's safe to fire unattended from a silent startup check as well as from
|
||||
the About dialog's "Check for updates" button.
|
||||
|
||||
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
|
||||
|
||||
_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):
|
||||
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.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:
|
||||
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):
|
||||
dlg = QDialog(self.window())
|
||||
dlg.setWindowTitle(f"Add {self._key_label}")
|
||||
@@ -269,6 +326,13 @@ class KeyValueTable(QWidget):
|
||||
grid.addWidget(val_edit, 1, 1)
|
||||
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.
|
||||
def _sync_echo(text):
|
||||
secret = core.is_secret_key(text)
|
||||
@@ -284,7 +348,15 @@ class KeyValueTable(QWidget):
|
||||
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
|
||||
ok_btn.setObjectName("primary")
|
||||
ok_btn.setEnabled(False)
|
||||
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
|
||||
|
||||
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.rejected.connect(dlg.reject)
|
||||
v.addWidget(btns)
|
||||
@@ -298,13 +370,15 @@ class KeyValueTable(QWidget):
|
||||
return
|
||||
k = key_edit.text().strip()
|
||||
val = val_edit.text()
|
||||
if not k:
|
||||
if not k or self._is_duplicate_key(k, ignore_row=-1):
|
||||
return
|
||||
if self._before_change:
|
||||
self._before_change()
|
||||
r = self.table.rowCount()
|
||||
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._changed()
|
||||
|
||||
@@ -323,7 +397,9 @@ class KeyValueTable(QWidget):
|
||||
for k, v in (d or {}).items():
|
||||
r = self.table.rowCount()
|
||||
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.blockSignals(False)
|
||||
|
||||
@@ -1247,6 +1323,31 @@ class LogViewerDialog(QDialog):
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1268,9 +1369,7 @@ class AboutDialog(QDialog):
|
||||
self._worker: UpdateCheckWorker | None = None
|
||||
self._release_url: str | None = None
|
||||
|
||||
icon_path = (
|
||||
Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
||||
)
|
||||
icon_path = _asset_dir() / "icons" / "twin-gears" / "rounded" / "icon-128.png"
|
||||
if icon_path.is_file():
|
||||
self.setWindowIcon(QIcon(str(icon_path)))
|
||||
|
||||
@@ -1394,6 +1493,17 @@ class AboutDialog(QDialog):
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1416,6 +1526,12 @@ class MainWindow(QMainWindow):
|
||||
self._focused_table = None
|
||||
self._row_of_index: dict[int, tuple] = {}
|
||||
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()
|
||||
self.setCentralWidget(central)
|
||||
@@ -1425,6 +1541,15 @@ class MainWindow(QMainWindow):
|
||||
|
||||
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.
|
||||
split = QSplitter(Qt.Orientation.Horizontal)
|
||||
split.setChildrenCollapsible(False)
|
||||
@@ -1476,14 +1601,16 @@ class MainWindow(QMainWindow):
|
||||
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
|
||||
if (time.time() - last) < 86400: # at most once/day
|
||||
return
|
||||
st.setValue("update/lastCheck", time.time())
|
||||
self._startup_update_worker = UpdateCheckWorker()
|
||||
self._startup_update_worker.done.connect(self._on_startup_update_checked)
|
||||
self._startup_update_worker.start()
|
||||
|
||||
def _on_startup_update_checked(self, release: dict | None):
|
||||
self._startup_update_worker = None
|
||||
if release and core.is_newer_version(core.__version__, release["version"]):
|
||||
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(
|
||||
f"Update available: {release['version']} · Help ▸ About to view it."
|
||||
)
|
||||
@@ -1533,10 +1660,10 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# --- left (server table) -------------------------------------------- #
|
||||
def _make_server_table(self, object_name=None):
|
||||
t = QTableWidget(0, 4)
|
||||
t = QTableWidget(0, 5)
|
||||
if object_name:
|
||||
t.setObjectName(object_name)
|
||||
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"])
|
||||
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"])
|
||||
t.verticalHeader().setVisible(False)
|
||||
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
||||
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
@@ -1546,6 +1673,7 @@ class MainWindow(QMainWindow):
|
||||
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
h.setSectionResizeMode(2, 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.itemChanged.connect(self._table_item_changed)
|
||||
return t
|
||||
@@ -1560,6 +1688,46 @@ class MainWindow(QMainWindow):
|
||||
head.setObjectName("h1")
|
||||
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
|
||||
# can drag the divider instead of being stuck with a fixed-height
|
||||
# disabled list.
|
||||
@@ -1616,12 +1784,17 @@ class MainWindow(QMainWindow):
|
||||
self.undo_btn = QPushButton("Undo")
|
||||
self.undo_btn.setEnabled(False)
|
||||
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.dup_btn.clicked.connect(self.duplicate_server)
|
||||
self.del_btn.clicked.connect(self.delete_server)
|
||||
self.paste_btn.clicked.connect(self.paste_json)
|
||||
self.copy_btn.clicked.connect(self.copy_to_menu)
|
||||
self.undo_btn.clicked.connect(self._undo)
|
||||
self.test_all_btn.clicked.connect(self._test_all_servers)
|
||||
for b in (
|
||||
self.add_btn,
|
||||
self.dup_btn,
|
||||
@@ -1629,6 +1802,7 @@ class MainWindow(QMainWindow):
|
||||
self.paste_btn,
|
||||
self.copy_btn,
|
||||
self.undo_btn,
|
||||
self.test_all_btn,
|
||||
):
|
||||
bar.addWidget(b)
|
||||
# Ctrl+Z shortcut
|
||||
@@ -1636,6 +1810,12 @@ class MainWindow(QMainWindow):
|
||||
undo_action.setShortcut("Ctrl+Z")
|
||||
undo_action.triggered.connect(self._undo)
|
||||
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()
|
||||
self.validation_lbl = QLabel("")
|
||||
bar.addWidget(self.validation_lbl)
|
||||
@@ -1662,6 +1842,19 @@ class MainWindow(QMainWindow):
|
||||
self._mark_dirty()
|
||||
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 -------------------------------------------------------- #
|
||||
def reload_profiles(self):
|
||||
discovered = core.discover_profiles()
|
||||
@@ -1679,6 +1872,26 @@ class MainWindow(QMainWindow):
|
||||
self.load_profile(self.profiles[0])
|
||||
else:
|
||||
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):
|
||||
start = str(core.app_support_base())
|
||||
@@ -1733,6 +1946,8 @@ class MainWindow(QMainWindow):
|
||||
self.restart_btn.hide()
|
||||
self._undo_stack.clear()
|
||||
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._update_status(saved=False)
|
||||
if repaired:
|
||||
@@ -1769,6 +1984,14 @@ class MainWindow(QMainWindow):
|
||||
QColor(STATUS_COLORS.get(dep["status"], MUTED))
|
||||
) # status stays colored even when off
|
||||
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)
|
||||
|
||||
def _refresh_tables(self, select_index=None):
|
||||
@@ -1777,8 +2000,16 @@ class MainWindow(QMainWindow):
|
||||
self._row_of_index = {}
|
||||
self.active_table.setRowCount(0)
|
||||
self.disabled_table.setRowCount(0)
|
||||
query = self._filter_query
|
||||
n_active = n_disabled = 0
|
||||
total_active = total_disabled = 0
|
||||
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:
|
||||
self._add_row(self.active_table, i, s)
|
||||
n_active += 1
|
||||
@@ -1787,8 +2018,18 @@ class MainWindow(QMainWindow):
|
||||
n_disabled += 1
|
||||
self.active_table.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_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._suppress_table = False
|
||||
self._suppress_sel = False
|
||||
@@ -1800,6 +2041,125 @@ class MainWindow(QMainWindow):
|
||||
self._load_editor_from_selection()
|
||||
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):
|
||||
base = f"{title} · {n}"
|
||||
if miss:
|
||||
@@ -1902,10 +2262,18 @@ class MainWindow(QMainWindow):
|
||||
if not (0 <= idx < len(self.servers)):
|
||||
return
|
||||
entry = self.servers[idx]
|
||||
old_name = entry.name
|
||||
entry.name = self.editor.current_name()
|
||||
entry.data = self.editor.dump_data()
|
||||
# The server stays in its section (enable state unchanged), so update
|
||||
# its existing row in place rather than re-rendering.
|
||||
# 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)
|
||||
if loc:
|
||||
table, row = loc
|
||||
@@ -1916,6 +2284,13 @@ class MainWindow(QMainWindow):
|
||||
st = table.item(row, 3)
|
||||
st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}")
|
||||
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._refresh_badges()
|
||||
self._mark_dirty()
|
||||
@@ -2057,11 +2432,23 @@ class MainWindow(QMainWindow):
|
||||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||
self.save_btn.setEnabled(False)
|
||||
return False
|
||||
self.validation_lbl.setText("✓ valid")
|
||||
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
||||
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.setStyleSheet(f"color: {GOOD};")
|
||||
self.save_btn.setEnabled(self.dirty)
|
||||
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):
|
||||
if not self.current_profile:
|
||||
return
|
||||
@@ -2131,18 +2518,30 @@ class MainWindow(QMainWindow):
|
||||
def _offer_restart_button(self):
|
||||
"""Show the 'Restart Claude Desktop' button after a successful save,
|
||||
but only when the just-saved profile is Claude Desktop -- restarting
|
||||
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):
|
||||
makes no sense for Claude Code, which has no GUI process to bounce --
|
||||
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()
|
||||
else:
|
||||
self.restart_btn.hide()
|
||||
|
||||
def _restart_claude_desktop(self):
|
||||
self.restart_btn.setEnabled(False)
|
||||
try:
|
||||
result = core.restart_claude_desktop()
|
||||
finally:
|
||||
self.restart_btn.setEnabled(True)
|
||||
self.restart_btn.setText("Restarting…")
|
||||
# Held on self (MainWindow outlives the worker); replaced only after
|
||||
# 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.setText("Restart Claude Desktop")
|
||||
self.restart_btn.hide()
|
||||
if result.success:
|
||||
self.status.setText(f"{self.status.text()} · {result.detail}")
|
||||
@@ -2203,29 +2602,44 @@ class MainWindow(QMainWindow):
|
||||
e.acceptProposedAction()
|
||||
|
||||
def dropEvent(self, e):
|
||||
total_added, total_replaced, files_imported = 0, 0, 0
|
||||
undo_pushed = False
|
||||
for u in e.mimeData().urls():
|
||||
path = u.toLocalFile()
|
||||
if not path.endswith(".json"):
|
||||
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:
|
||||
servers = core.parse_pasted_json(text)
|
||||
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
|
||||
self._push_undo()
|
||||
if not undo_pushed:
|
||||
self._push_undo()
|
||||
undo_pushed = True
|
||||
added, replaced = 0, 0
|
||||
for name, data in servers.items():
|
||||
a, r = self._import_server(name, data)
|
||||
added += int(a)
|
||||
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._mark_dirty()
|
||||
self.status.setText(
|
||||
f"Imported {added} added, {replaced} replaced from {Path(path).name}. "
|
||||
"Review and Save."
|
||||
f"Imported {total_added} added, {total_replaced} replaced from "
|
||||
f"{files_imported} file(s). Review and Save."
|
||||
)
|
||||
break
|
||||
|
||||
def closeEvent(self, e):
|
||||
if self.dirty and not self._confirm_discard():
|
||||
@@ -2236,9 +2650,23 @@ class MainWindow(QMainWindow):
|
||||
|
||||
|
||||
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.setApplicationName("Better Claude Config")
|
||||
app.setApplicationDisplayName("Better Claude Config")
|
||||
icon = _app_icon()
|
||||
if not icon.isNull():
|
||||
app.setWindowIcon(icon)
|
||||
app.setStyleSheet(STYLESHEET)
|
||||
win = MainWindow()
|
||||
win.show()
|
||||
|
||||
@@ -31,7 +31,7 @@ a = Analysis(
|
||||
["bcc.py"],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[],
|
||||
datas=[("icons", "icons")],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
||||
info_plist={
|
||||
"CFBundleName": "Better Claude Config",
|
||||
"CFBundleDisplayName": "Better Claude Config",
|
||||
"CFBundleShortVersionString": "1.0.0",
|
||||
"CFBundleVersion": "1.0.0",
|
||||
"CFBundleShortVersionString": "1.3.0",
|
||||
"CFBundleVersion": "1.3.0",
|
||||
"NSHighResolutionCapable": True,
|
||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||
"LSMinimumSystemVersion": "11.0",
|
||||
|
||||
+440
-24
@@ -39,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json"
|
||||
# we can toggle it back on without losing the definition.
|
||||
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"
|
||||
MAX_BACKUPS = 15
|
||||
|
||||
@@ -59,7 +65,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
||||
# 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.
|
||||
# --------------------------------------------------------------------------- #
|
||||
__version__ = "1.2.0"
|
||||
__version__ = "1.3.0"
|
||||
|
||||
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
||||
ISSUES_URL = f"{REPO_URL}/issues"
|
||||
@@ -191,17 +197,150 @@ def app_support_base() -> Path:
|
||||
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]:
|
||||
"""
|
||||
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*`
|
||||
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 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
|
||||
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()
|
||||
out: list[Profile] = []
|
||||
@@ -213,10 +352,22 @@ def discover_profiles() -> list[Profile]:
|
||||
cfg = d / CONFIG_FILENAME
|
||||
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()
|
||||
cc_cfg = home / ".claude.json"
|
||||
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
|
||||
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
||||
# file only when it actually contains an mcpServers block, so the user can
|
||||
@@ -307,6 +458,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
|
||||
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:
|
||||
"""
|
||||
Return a name guaranteed not to collide with `existing`.
|
||||
@@ -1111,6 +1326,89 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
||||
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
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -1453,6 +1751,23 @@ def server_log_path(name: str) -> Path | None:
|
||||
_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:
|
||||
"""
|
||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||
@@ -1463,12 +1778,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
"crashed" — exited with a non-zero code before timeout
|
||||
"not_found" — command could not be resolved to an executable
|
||||
"not_applicable" — remote server or no command; nothing to spawn
|
||||
"error" — unexpected internal failure while spawning/observing
|
||||
returncode: int | None
|
||||
stderr: str (first ~4 KB)
|
||||
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.
|
||||
"""
|
||||
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:
|
||||
return {
|
||||
"outcome": "not_applicable",
|
||||
@@ -1477,7 +1809,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
"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:
|
||||
return {
|
||||
"outcome": "not_applicable",
|
||||
@@ -1499,7 +1833,8 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
|
||||
|
||||
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] = []
|
||||
|
||||
@@ -1527,6 +1862,10 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
)
|
||||
if os.name != "nt":
|
||||
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:
|
||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||
@@ -1548,7 +1887,7 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||
if os.name != "nt":
|
||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||
else:
|
||||
proc.kill() # best-effort on Windows
|
||||
_kill_process_tree_windows(proc.pid)
|
||||
except OSError:
|
||||
pass
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
@@ -1580,6 +1919,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]:
|
||||
"""
|
||||
Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
|
||||
@@ -1662,8 +2048,41 @@ def _run_quiet(cmd: list[str]) -> None:
|
||||
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:
|
||||
_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:
|
||||
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
|
||||
except OSError as e:
|
||||
@@ -1680,8 +2099,18 @@ def _claude_windows_start_menu_shortcut() -> Path:
|
||||
|
||||
|
||||
def _restart_claude_desktop_windows() -> RestartResult:
|
||||
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
|
||||
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:
|
||||
# `cmd /c start "" <target>` launches detached, the same as double-clicking
|
||||
# the Start-menu shortcut, and returns immediately.
|
||||
@@ -1698,32 +2127,19 @@ def _restart_claude_desktop_windows() -> RestartResult:
|
||||
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:
|
||||
"""
|
||||
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
|
||||
exiting non-zero just means "nothing to kill", and we go straight to
|
||||
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":
|
||||
return _restart_claude_desktop_macos()
|
||||
if sys.platform.startswith("win"):
|
||||
return _restart_claude_desktop_windows()
|
||||
return _restart_claude_desktop_linux()
|
||||
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
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"
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
|
||||
+658
-44
@@ -3,9 +3,11 @@ proper test functions with tmp_path/monkeypatch fixtures)."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -65,6 +67,64 @@ def test_legacy_settings_json_surfaced_only_with_servers(fake_home):
|
||||
assert any("legacy" in p.label for p in c.discover_profiles())
|
||||
|
||||
|
||||
def test_project_with_mcp_json_is_discovered(tmp_path):
|
||||
proj = tmp_path / "my-project"
|
||||
proj.mkdir()
|
||||
(proj / ".mcp.json").write_text('{"mcpServers": {"x": {"command": "npx"}}}')
|
||||
claude_json = tmp_path / ".claude.json"
|
||||
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||
|
||||
profs = c.discover_project_configs(claude_json)
|
||||
assert len(profs) == 1
|
||||
assert profs[0].label == f"Project: {proj.name}"
|
||||
assert profs[0].path == proj / ".mcp.json"
|
||||
assert profs[0].config_exists
|
||||
|
||||
|
||||
def test_project_without_mcp_json_not_listed(tmp_path):
|
||||
proj = tmp_path / "no-mcp-project"
|
||||
proj.mkdir()
|
||||
claude_json = tmp_path / ".claude.json"
|
||||
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||
|
||||
assert c.discover_project_configs(claude_json) == []
|
||||
|
||||
|
||||
def test_projects_missing_or_not_dict_yields_no_profiles(tmp_path):
|
||||
claude_json = tmp_path / ".claude.json"
|
||||
|
||||
claude_json.write_text(json.dumps({}))
|
||||
assert c.discover_project_configs(claude_json) == []
|
||||
|
||||
claude_json.write_text(json.dumps({"projects": ["not", "a", "dict"]}))
|
||||
assert c.discover_project_configs(claude_json) == []
|
||||
|
||||
|
||||
def test_malformed_claude_json_fails_quiet(fake_home):
|
||||
(fake_home / ".claude.json").write_text("{not valid json")
|
||||
assert c.discover_project_configs(fake_home / ".claude.json") == []
|
||||
# discover_profiles as a whole must still work and just skip project profiles
|
||||
profs = c.discover_profiles()
|
||||
assert not any(p.label.startswith("Project:") for p in profs)
|
||||
|
||||
|
||||
def test_project_configs_deduped_against_existing_profiles(fake_home):
|
||||
# Point a project directly at the Claude Code profile's own .mcp.json-shaped
|
||||
# path to prove discover_profiles() won't duplicate an already-listed path.
|
||||
proj = fake_home / "dup-project"
|
||||
proj.mkdir()
|
||||
mcp_path = proj / ".mcp.json"
|
||||
mcp_path.write_text('{"mcpServers": {"x": {"command": "npx"}}}')
|
||||
(fake_home / ".claude.json").write_text(json.dumps({"projects": {str(proj): {}}}))
|
||||
|
||||
direct = c.discover_project_configs(fake_home / ".claude.json")
|
||||
assert len(direct) == 1
|
||||
|
||||
profs = c.discover_profiles()
|
||||
project_profiles = [p for p in profs if str(p.path) == str(mcp_path)]
|
||||
assert len(project_profiles) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 2. Write pipeline: preserves other keys + order, only touches mcpServers
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -179,6 +239,85 @@ def test_valid_set_passes_clean():
|
||||
assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 4b. Lint: non-blocking structural warnings
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_lint_flags_non_string_command():
|
||||
warnings = c.lint_server("x", {"command": 5})
|
||||
assert any("'command' should be a string" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_args_not_a_list():
|
||||
warnings = c.lint_server("x", {"command": "node", "args": "-y foo"})
|
||||
assert any("'args' should be a list" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_args_with_non_string_items():
|
||||
warnings = c.lint_server("x", {"command": "node", "args": ["-y", 5]})
|
||||
assert any("'args' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_env_not_a_dict():
|
||||
warnings = c.lint_server("x", {"command": "node", "env": ["FOO=bar"]})
|
||||
assert any("'env' should be an object" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_env_with_non_string_values():
|
||||
warnings = c.lint_server("x", {"command": "node", "env": {"FOO": 5}})
|
||||
assert any("'env' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_headers_not_a_dict():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "headers": ["Authorization: x"]})
|
||||
assert any("'headers' should be an object" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_headers_with_non_string_values():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "headers": {"Authorization": 5}})
|
||||
assert any("'headers' contains non-string values" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_bad_type_value():
|
||||
warnings = c.lint_server("x", {"url": "https://x", "type": "websocket"})
|
||||
assert any("'type'" in w and "websocket" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_flags_extra_unknown_fields():
|
||||
warnings = c.lint_server("x", {"command": "node", "cwd": "/tmp", "timeout": 30})
|
||||
matches = [w for w in warnings if "extra fields preserved as-is" in w]
|
||||
assert len(matches) == 1
|
||||
assert "cwd" in matches[0]
|
||||
assert "timeout" in matches[0]
|
||||
|
||||
|
||||
def test_lint_clean_server_yields_no_warnings():
|
||||
assert c.lint_server("good", {"command": "node", "args": ["a.js"]}) == []
|
||||
assert (
|
||||
c.lint_server(
|
||||
"remote", {"url": "https://x", "type": "http", "headers": {"Authorization": "x"}}
|
||||
)
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_lint_servers_aggregates_across_entries():
|
||||
entries = [
|
||||
c.ServerEntry("a", {"command": 5}, True),
|
||||
c.ServerEntry("b", {"url": "https://x", "type": "bogus"}, True),
|
||||
]
|
||||
warnings = c.lint_servers(entries)
|
||||
assert any("'a'" in w and "'command' should be a string" in w for w in warnings)
|
||||
assert any("'b'" in w and "'type'" in w for w in warnings)
|
||||
|
||||
|
||||
def test_lint_warning_is_not_a_blocking_problem():
|
||||
# args given as a single string isn't caught by validate_servers (a
|
||||
# command is still present), but it's a lint warning.
|
||||
entry = c.ServerEntry("x", {"command": "node", "args": "-y foo"}, True)
|
||||
assert c.validate_servers([entry]) == []
|
||||
assert c.lint_servers([entry]) != []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 5. Secrets: detection + redaction
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -227,8 +366,11 @@ def test_diagnostics_redacts_token_args():
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 6. Dependency / PATH checking
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_dep_check_finds_python3():
|
||||
assert c.check_dependency({"command": "python3"})["status"] == "ok"
|
||||
def test_dep_check_finds_python():
|
||||
# "python3" does not exist on a stock Windows install; "warn" (found only
|
||||
# on an augmented PATH) still proves resolution works on a real machine.
|
||||
cmd = "python" if os.name == "nt" else "python3"
|
||||
assert c.check_dependency({"command": cmd})["status"] in ("ok", "warn")
|
||||
|
||||
|
||||
def test_dep_check_flags_missing():
|
||||
@@ -326,6 +468,91 @@ def test_spawn_test_large_stderr_does_not_deadlock():
|
||||
assert len(r["stderr"]) <= c._STDERR_CAP
|
||||
|
||||
|
||||
def test_windows_tree_kill_uses_taskkill(monkeypatch):
|
||||
"""The Windows kill path must walk the process tree (taskkill /T /F);
|
||||
Popen.kill() alone orphans grandchildren (issue #13)."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
return _FakeRC()
|
||||
|
||||
class _FakeRC:
|
||||
returncode = 0
|
||||
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
c._kill_process_tree_windows(1234)
|
||||
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
|
||||
|
||||
|
||||
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
|
||||
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
|
||||
"""A spawn-tested parent that has its own child (npx -> node style) must
|
||||
not leave the child running after the timeout kill (issue #13)."""
|
||||
import subprocess as sp
|
||||
import time as _t
|
||||
|
||||
pid_file = tmp_path / "child.pid"
|
||||
parent_script = (
|
||||
"import subprocess, sys, time\n"
|
||||
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
|
||||
"time.sleep(60)\n"
|
||||
)
|
||||
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
|
||||
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
|
||||
assert pid_file.is_file(), "parent never spawned its child"
|
||||
child_pid = int(pid_file.read_text())
|
||||
|
||||
deadline = _t.time() + 5.0
|
||||
alive = True
|
||||
while _t.time() < deadline:
|
||||
out = sp.run(
|
||||
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
|
||||
).stdout
|
||||
alive = str(child_pid) in out
|
||||
if not alive:
|
||||
break
|
||||
_t.sleep(0.25)
|
||||
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
|
||||
|
||||
|
||||
def test_spawn_test_non_string_env_value():
|
||||
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
|
||||
# round-trip through the editor. Popen rejects non-str env; spawn_test must
|
||||
# coerce instead of letting TypeError escape (which would leave the GUI's
|
||||
# Test launch / Test all buttons stuck disabled).
|
||||
r = c.spawn_test(
|
||||
{
|
||||
"command": sys.executable,
|
||||
"args": ["-c", "import os; raise SystemExit(0 if os.environ['PORT'] == '8080' else 1)"],
|
||||
"env": {"PORT": 8080},
|
||||
},
|
||||
timeout=2.0,
|
||||
)
|
||||
assert r["outcome"] == "exited"
|
||||
assert r["returncode"] == 0
|
||||
|
||||
|
||||
def test_spawn_test_non_string_command():
|
||||
# A non-string command must classify, not raise (str(123) resolves to nothing).
|
||||
r = c.spawn_test({"command": 123}, timeout=0.3)
|
||||
assert r["outcome"] == "not_found"
|
||||
|
||||
|
||||
def test_spawn_test_internal_error_yields_result(monkeypatch):
|
||||
# Any unexpected exception inside the spawn path must come back as a result
|
||||
# dict (outcome "error"), never escape — the UI only re-enables its buttons
|
||||
# when a result arrives.
|
||||
def boom(*a, **k):
|
||||
raise RuntimeError("simulated internal failure")
|
||||
|
||||
monkeypatch.setattr(c.shutil, "which", boom)
|
||||
r = c.spawn_test({"command": "python3"}, timeout=0.3)
|
||||
assert r["outcome"] == "error"
|
||||
assert "simulated internal failure" in r["detail"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# 8. Backup restore
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -679,6 +906,68 @@ def test_args_secret_warning_empty():
|
||||
assert c.args_secret_warning({"args": []}) is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Named server sets (issue #52)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _three_servers():
|
||||
return [
|
||||
c.ServerEntry("alpha", {"command": "npx"}, True),
|
||||
c.ServerEntry("beta", {"command": "uvx"}, True),
|
||||
c.ServerEntry("gamma", {"url": "https://x.example/mcp"}, False),
|
||||
]
|
||||
|
||||
|
||||
def test_save_and_list_server_sets_roundtrip():
|
||||
cfg: dict = {}
|
||||
members = c.save_server_set(cfg, "webdev", _three_servers())
|
||||
assert members == ["alpha", "beta"] # only the enabled ones
|
||||
assert c.list_server_sets(cfg) == {"webdev": ["alpha", "beta"]}
|
||||
|
||||
|
||||
def test_apply_server_set_enables_exactly_the_members():
|
||||
servers = _three_servers()
|
||||
missing = c.apply_server_set(servers, ["gamma"])
|
||||
assert missing == []
|
||||
assert [s.enabled for s in servers] == [False, False, True]
|
||||
|
||||
|
||||
def test_apply_server_set_reports_missing_members():
|
||||
servers = _three_servers()
|
||||
missing = c.apply_server_set(servers, ["alpha", "vanished", "gone"])
|
||||
assert missing == ["gone", "vanished"]
|
||||
assert [s.enabled for s in servers] == [True, False, False] # rest still applied
|
||||
|
||||
|
||||
def test_delete_server_set_drops_empty_key():
|
||||
cfg: dict = {}
|
||||
c.save_server_set(cfg, "only", _three_servers())
|
||||
assert c.delete_server_set(cfg, "only") is True
|
||||
assert c.SETS_KEY not in cfg # no empty bcc key left behind
|
||||
assert c.delete_server_set(cfg, "only") is False
|
||||
|
||||
|
||||
def test_server_sets_survive_write_and_reload(tmp_path):
|
||||
cfgpath = tmp_path / "cfg.json"
|
||||
cfg = {"mcpServers": {"alpha": {"command": "npx"}}}
|
||||
c.save_server_set(cfg, "webdev", [c.ServerEntry("alpha", {"command": "npx"}, True)])
|
||||
c.write_config(cfgpath, cfg)
|
||||
reloaded = c.load_config(cfgpath)
|
||||
assert c.list_server_sets(reloaded) == {"webdev": ["alpha"]}
|
||||
|
||||
|
||||
def test_apply_servers_preserves_sets_key():
|
||||
cfg: dict = {"mcpServers": {}}
|
||||
c.save_server_set(cfg, "s", [c.ServerEntry("alpha", {"command": "npx"}, True)])
|
||||
c.apply_servers(cfg, _three_servers())
|
||||
assert c.SETS_KEY in cfg # the server writer never touches sets
|
||||
|
||||
|
||||
def test_list_server_sets_skips_malformed_entries():
|
||||
cfg = {c.SETS_KEY: {"good": ["a"], "bad-not-list": "a", "bad-items": ["a", 3]}}
|
||||
assert c.list_server_sets(cfg) == {"good": ["a"]}
|
||||
assert c.list_server_sets({c.SETS_KEY: "junk"}) == {}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# resolve_name_collision (paste/import duplicate-name handling — issue #8)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -758,6 +1047,152 @@ def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch
|
||||
assert c.server_log_path("brave-search") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# MSIX-virtualized Claude Desktop config detection (issue #7)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def _make_msix_pkg(localappdata, pkg_name="AnthropicClaude_abc123xyz", with_config=True):
|
||||
cfg_dir = localappdata / "Packages" / pkg_name / "LocalCache" / "Roaming" / "Claude"
|
||||
cfg_dir.mkdir(parents=True)
|
||||
cfg_path = cfg_dir / c.CONFIG_FILENAME
|
||||
if with_config:
|
||||
cfg_path.write_text('{"mcpServers": {}}')
|
||||
return cfg_path
|
||||
|
||||
|
||||
def test_msix_config_paths_finds_package_config(tmp_path):
|
||||
local = tmp_path / "Local"
|
||||
expected = _make_msix_pkg(local)
|
||||
assert c.msix_config_paths(local) == [expected]
|
||||
|
||||
|
||||
def test_msix_config_paths_ignores_non_claude_packages(tmp_path):
|
||||
local = tmp_path / "Local"
|
||||
(local / "Packages" / "SomeOtherApp_xyz" / "LocalCache" / "Roaming").mkdir(parents=True)
|
||||
assert c.msix_config_paths(local) == []
|
||||
|
||||
|
||||
def test_msix_config_paths_empty_when_package_dir_has_no_config_yet(tmp_path):
|
||||
local = tmp_path / "Local"
|
||||
_make_msix_pkg(local, with_config=False)
|
||||
assert c.msix_config_paths(local) == []
|
||||
|
||||
|
||||
def test_msix_config_paths_empty_when_no_packages_dir(tmp_path):
|
||||
assert c.msix_config_paths(tmp_path / "Local") == []
|
||||
|
||||
|
||||
def test_msix_config_paths_uses_localappdata_env_by_default(tmp_path, monkeypatch):
|
||||
local = tmp_path / "Local"
|
||||
expected = _make_msix_pkg(local)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||
assert c.msix_config_paths() == [expected]
|
||||
|
||||
|
||||
def test_detect_msix_claude_returns_none_on_non_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
local = tmp_path / "Local"
|
||||
_make_msix_pkg(local)
|
||||
assert c.detect_msix_claude(localappdata=local) is None
|
||||
|
||||
|
||||
def test_detect_msix_claude_finds_virtualized_config_on_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
local = tmp_path / "Local"
|
||||
expected = _make_msix_pkg(local)
|
||||
assert c.detect_msix_claude(localappdata=local) == expected
|
||||
|
||||
|
||||
def test_detect_msix_claude_none_when_nothing_present_on_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
local = tmp_path / "Local"
|
||||
assert c.detect_msix_claude(localappdata=local) is None
|
||||
|
||||
|
||||
def test_msix_warning_text_none_on_non_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
local = tmp_path / "Local"
|
||||
_make_msix_pkg(local)
|
||||
assert c.msix_warning_text(localappdata=local) is None
|
||||
|
||||
|
||||
def test_msix_warning_text_none_when_nothing_detected(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
local = tmp_path / "Local"
|
||||
assert c.msix_warning_text(localappdata=local) is None
|
||||
|
||||
|
||||
def test_msix_warning_text_warns_when_virtualized_path_differs(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
roaming = tmp_path / "Roaming"
|
||||
local = tmp_path / "Local"
|
||||
real = _make_msix_pkg(local)
|
||||
warning = c.msix_warning_text(appdata=roaming, localappdata=local)
|
||||
assert warning is not None
|
||||
assert "Microsoft Store" in warning
|
||||
assert str(real) in warning
|
||||
assert str(roaming / "Claude" / c.CONFIG_FILENAME) in warning
|
||||
|
||||
|
||||
def test_msix_warning_text_none_when_plain_and_virtualized_paths_match(tmp_path, monkeypatch):
|
||||
# Degenerate case: if the "plain" APPDATA base is pointed at the exact
|
||||
# same tree the MSIX scan found (e.g. a symlink setup), there's nothing
|
||||
# surprising to warn about.
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
local = tmp_path / "Local"
|
||||
pkg_claude_dir = local / "Packages" / "AnthropicClaude_abc123xyz" / "LocalCache" / "Roaming"
|
||||
pkg_claude_dir.mkdir(parents=True)
|
||||
(pkg_claude_dir / "Claude").mkdir()
|
||||
(pkg_claude_dir / "Claude" / c.CONFIG_FILENAME).write_text("{}")
|
||||
warning = c.msix_warning_text(appdata=pkg_claude_dir, localappdata=local)
|
||||
assert warning is None
|
||||
|
||||
|
||||
def test_discover_profiles_adds_msix_profile_on_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
base = tmp_path / "AppSupport"
|
||||
base.mkdir()
|
||||
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||
local = tmp_path / "Local"
|
||||
real = _make_msix_pkg(local)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||
|
||||
profs = c.discover_profiles()
|
||||
msix = [p for p in profs if "MSIX" in p.label]
|
||||
assert len(msix) == 1
|
||||
assert msix[0].path == real
|
||||
assert msix[0].config_exists
|
||||
|
||||
|
||||
def test_discover_profiles_no_msix_profile_when_nothing_detected(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
base = tmp_path / "AppSupport"
|
||||
base.mkdir()
|
||||
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "Local"))
|
||||
|
||||
profs = c.discover_profiles()
|
||||
assert not any("MSIX" in p.label for p in profs)
|
||||
|
||||
|
||||
def test_discover_profiles_no_msix_profile_on_non_windows(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
base = tmp_path / "AppSupport"
|
||||
base.mkdir()
|
||||
monkeypatch.setattr(c, "app_support_base", lambda: base)
|
||||
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
|
||||
(tmp_path / "home" / ".claude").mkdir(parents=True)
|
||||
local = tmp_path / "Local"
|
||||
_make_msix_pkg(local)
|
||||
monkeypatch.setenv("LOCALAPPDATA", str(local))
|
||||
|
||||
profs = c.discover_profiles()
|
||||
assert not any("MSIX" in p.label for p in profs)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# restart_claude_desktop / profile_targets_claude_desktop (issue #9)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -788,11 +1223,13 @@ class _FakeCompletedProcess:
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||
"""macOS: pkill -x "Claude" then open -a Claude, in that order."""
|
||||
"""macOS: pkill -x "Claude", wait for exit (pgrep), then open -a Claude."""
|
||||
calls = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
calls.append(cmd)
|
||||
if cmd[0] == "pgrep":
|
||||
return _FakeCompletedProcess(returncode=1) # already exited
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
@@ -800,14 +1237,14 @@ def test_restart_claude_desktop_macos_commands(monkeypatch):
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert calls[0] == ["pkill", "-x", "Claude"]
|
||||
assert calls[1] == ["open", "-a", "Claude"]
|
||||
assert calls[-1] == ["open", "-a", "Claude"]
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_pkill_not_running_is_fine(monkeypatch):
|
||||
"""pkill exiting non-zero (nothing to kill) must NOT be treated as failure."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "pkill":
|
||||
if cmd[0] in ("pkill", "pgrep"):
|
||||
return _FakeCompletedProcess(returncode=1) # no matching process
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
@@ -831,12 +1268,12 @@ def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch):
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkeypatch):
|
||||
"""pkill raising OSError (binary missing) must be swallowed, not propagated --
|
||||
relaunch is still attempted."""
|
||||
"""pkill/pgrep raising OSError (binary missing) must be swallowed, not
|
||||
propagated -- relaunch is still attempted."""
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "pkill":
|
||||
raise OSError("pkill not found")
|
||||
if cmd[0] in ("pkill", "pgrep"):
|
||||
raise OSError("binary not found")
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
@@ -845,7 +1282,45 @@ def test_restart_claude_desktop_macos_pkill_binary_missing_does_not_raise(monkey
|
||||
assert result.success
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_commands(monkeypatch):
|
||||
def test_restart_claude_desktop_macos_gives_up_if_app_wont_quit(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||
monkeypatch.setattr(c, "_macos_claude_running", lambda: True) # never exits
|
||||
monkeypatch.setattr(c, "_MACOS_QUIT_WAIT_S", 0.2)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert "quit" in res.detail.lower()
|
||||
|
||||
|
||||
def test_restart_claude_desktop_macos_waits_for_exit_then_relaunches(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
|
||||
alive = iter([True, True, False]) # exits on the third poll
|
||||
monkeypatch.setattr(c, "_macos_claude_running", lambda: next(alive))
|
||||
|
||||
launched = []
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
launched.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is True
|
||||
assert launched == [["open", "-a", "Claude"]]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def windows_shortcut(tmp_path, monkeypatch):
|
||||
"""A fake %APPDATA% containing the Claude Start-menu shortcut."""
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path))
|
||||
lnk = tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
|
||||
lnk.parent.mkdir(parents=True)
|
||||
lnk.write_bytes(b"")
|
||||
return lnk
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_commands(monkeypatch, windows_shortcut):
|
||||
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
|
||||
calls = []
|
||||
|
||||
@@ -862,7 +1337,7 @@ def test_restart_claude_desktop_windows_commands(monkeypatch):
|
||||
assert calls[1][-1].endswith("Claude.lnk")
|
||||
|
||||
|
||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
||||
def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch, windows_shortcut):
|
||||
def fake_run(cmd, **kwargs):
|
||||
if cmd[0] == "cmd":
|
||||
return _FakeCompletedProcess(returncode=1, stderr="not found")
|
||||
@@ -875,43 +1350,37 @@ def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch):
|
||||
assert "not found" in result.detail
|
||||
|
||||
|
||||
def test_restart_claude_desktop_linux_commands(monkeypatch):
|
||||
"""Linux: pkill claude, then relaunch via a detached Popen (no waiting)."""
|
||||
run_calls = []
|
||||
popen_calls = []
|
||||
def test_restart_claude_desktop_windows_missing_shortcut_refuses_before_killing(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
killed = []
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
monkeypatch.setenv("APPDATA", str(tmp_path)) # no Claude.lnk under here (MSIX case)
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert "shortcut" in res.detail.lower()
|
||||
assert killed == [] # Claude must NOT be killed when it can't be relaunched
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
run_calls.append(cmd)
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
class _FakePopen:
|
||||
def __init__(self, cmd, **kwargs):
|
||||
popen_calls.append((cmd, kwargs))
|
||||
|
||||
def test_restart_supported_only_on_desktop_platforms(monkeypatch):
|
||||
monkeypatch.setattr(c.sys, "platform", "darwin")
|
||||
assert c.restart_supported()
|
||||
monkeypatch.setattr(c.sys, "platform", "win32")
|
||||
assert c.restart_supported()
|
||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(c.subprocess, "Popen", _FakePopen)
|
||||
result = c.restart_claude_desktop()
|
||||
assert result.success
|
||||
assert run_calls == [["pkill", "claude"]]
|
||||
cmd, kwargs = popen_calls[0]
|
||||
assert cmd == ["claude"]
|
||||
assert kwargs.get("start_new_session") is True
|
||||
assert not c.restart_supported()
|
||||
|
||||
|
||||
def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
|
||||
def fake_run(cmd, **kwargs):
|
||||
return _FakeCompletedProcess(returncode=0)
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
raise OSError("no such file or directory")
|
||||
|
||||
def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeypatch):
|
||||
"""There is no Claude Desktop on Linux; 'pkill claude' would substring-match
|
||||
running Claude Code CLI sessions. The restart must refuse outright."""
|
||||
killed = []
|
||||
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
|
||||
monkeypatch.setattr(c.sys, "platform", "linux")
|
||||
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(c.subprocess, "Popen", fake_popen)
|
||||
result = c.restart_claude_desktop()
|
||||
assert not result.success
|
||||
assert "Claude Desktop" in result.detail
|
||||
res = c.restart_claude_desktop()
|
||||
assert res.success is False
|
||||
assert killed == [] # nothing killed, nothing spawned
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -919,8 +1388,12 @@ def test_restart_claude_desktop_linux_popen_failure_reported(monkeypatch):
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_dunder_version_matches_pyproject():
|
||||
# Guards against the version drifting out of sync between the two places
|
||||
# a human might bump it.
|
||||
assert c.__version__ == "1.2.0"
|
||||
# a human might bump it. Reads pyproject.toml rather than hard-coding a
|
||||
# version here (which would make this a third place to bump).
|
||||
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
|
||||
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||
assert m, "no [project] version found in pyproject.toml"
|
||||
assert c.__version__ == m.group(1)
|
||||
|
||||
|
||||
def test_parse_version_basic():
|
||||
@@ -1054,3 +1527,144 @@ def test_fetch_latest_release_malformed_json_returns_none(monkeypatch):
|
||||
urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(b"not json")
|
||||
)
|
||||
assert c.fetch_latest_release() is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Server search / filter (#27)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_filter_matches_name():
|
||||
e = c.ServerEntry("brave-search", {"command": "npx", "args": ["-y", "@x/brave"]}, True)
|
||||
assert c.server_matches_filter(e, "brave")
|
||||
|
||||
|
||||
def test_filter_matches_stdio_command():
|
||||
e = c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True)
|
||||
assert c.server_matches_filter(e, "uvx")
|
||||
|
||||
|
||||
def test_filter_matches_remote_url():
|
||||
e = c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True)
|
||||
assert c.server_matches_filter(e, "example.com")
|
||||
|
||||
|
||||
def test_filter_is_case_insensitive():
|
||||
e = c.ServerEntry("BraveSearch", {"command": "NPX", "args": []}, True)
|
||||
assert c.server_matches_filter(e, "bravesearch")
|
||||
assert c.server_matches_filter(e, "npx")
|
||||
|
||||
|
||||
def test_filter_empty_query_matches_everything():
|
||||
e = c.ServerEntry("anything", {"command": "node", "args": []}, True)
|
||||
assert c.server_matches_filter(e, "")
|
||||
assert c.server_matches_filter(e, " ")
|
||||
|
||||
|
||||
def test_filter_no_match_returns_false():
|
||||
e = c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True)
|
||||
assert not c.server_matches_filter(e, "nonexistent")
|
||||
|
||||
|
||||
def test_filter_remote_query_does_not_match_stdio_command_field():
|
||||
e = c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True)
|
||||
assert not c.server_matches_filter(e, "npx")
|
||||
|
||||
|
||||
def test_filter_servers_returns_only_matches():
|
||||
servers = [
|
||||
c.ServerEntry("brave-search", {"command": "npx", "args": []}, True),
|
||||
c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True),
|
||||
c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True),
|
||||
]
|
||||
assert [s.name for s in c.filter_servers(servers, "brave")] == ["brave-search"]
|
||||
assert [s.name for s in c.filter_servers(servers, "")] == [s.name for s in servers]
|
||||
assert c.filter_servers(servers, "zzz-nope") == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Health status mapping (#28)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_health_from_spawn_result_ok():
|
||||
result = {
|
||||
"outcome": "ok",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "still running after 3s — server started successfully",
|
||||
}
|
||||
status, summary = c.health_from_spawn_result(result)
|
||||
assert status == c.HealthStatus.OK
|
||||
assert "started" in summary
|
||||
|
||||
|
||||
def test_health_from_spawn_result_not_applicable_is_untested():
|
||||
result = {
|
||||
"outcome": "not_applicable",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "remote server",
|
||||
}
|
||||
status, summary = c.health_from_spawn_result(result)
|
||||
assert status == c.HealthStatus.UNTESTED
|
||||
assert summary == "remote server"
|
||||
|
||||
|
||||
def test_health_from_spawn_result_crashed_is_failed():
|
||||
result = {
|
||||
"outcome": "crashed",
|
||||
"returncode": 1,
|
||||
"stderr": "Traceback: boom\nsecond line",
|
||||
"detail": "process exited with code 1",
|
||||
}
|
||||
status, summary = c.health_from_spawn_result(result)
|
||||
assert status == c.HealthStatus.FAILED
|
||||
assert "process exited with code 1" in summary
|
||||
assert "Traceback: boom" in summary
|
||||
|
||||
|
||||
def test_health_from_spawn_result_exited_is_failed():
|
||||
result = {
|
||||
"outcome": "exited",
|
||||
"returncode": 0,
|
||||
"stderr": "",
|
||||
"detail": "process exited cleanly (code 0) — unusual",
|
||||
}
|
||||
status, summary = c.health_from_spawn_result(result)
|
||||
assert status == c.HealthStatus.FAILED
|
||||
assert "exited cleanly" in summary
|
||||
|
||||
|
||||
def test_health_from_spawn_result_not_found_is_failed():
|
||||
result = {
|
||||
"outcome": "not_found",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "command not found: totallybogus",
|
||||
}
|
||||
status, summary = c.health_from_spawn_result(result)
|
||||
assert status == c.HealthStatus.FAILED
|
||||
assert "not found" in summary
|
||||
|
||||
|
||||
def test_health_from_spawn_result_failed_without_stderr_has_no_dash():
|
||||
result = {
|
||||
"outcome": "not_found",
|
||||
"returncode": None,
|
||||
"stderr": "",
|
||||
"detail": "command not found: x",
|
||||
}
|
||||
_, summary = c.health_from_spawn_result(result)
|
||||
assert "—" not in summary
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# App icon assets present (issue #20 — icons must exist to be bundled/loaded)
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_app_icon_assets_present():
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(c.__file__).resolve().parent
|
||||
rounded = root / "icons" / "twin-gears" / "rounded"
|
||||
# The window-icon builder in bcc.py loads these sizes; keep them present.
|
||||
for size in (16, 32, 128, 256):
|
||||
assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png"
|
||||
assert (root / "icons" / "app.ico").is_file()
|
||||
assert (root / "icons" / "app.icns").is_file()
|
||||
|
||||
Reference in New Issue
Block a user