Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e6b60e94e7 | |||
| 4afe21666d | |||
| 06e74d4d2c | |||
| f92b851127 | |||
| 47c95ac006 | |||
| 6b22ad26f0 | |||
| 874948506c | |||
| ac2e73e9d7 | |||
| 82ff149373 | |||
| 31ef4a0e85 | |||
| 520b1b2ffd |
@@ -23,6 +23,7 @@ from PySide6.QtGui import (
|
|||||||
QDesktopServices,
|
QDesktopServices,
|
||||||
QGuiApplication,
|
QGuiApplication,
|
||||||
QIcon,
|
QIcon,
|
||||||
|
QKeySequence,
|
||||||
QPainter,
|
QPainter,
|
||||||
QPixmap,
|
QPixmap,
|
||||||
)
|
)
|
||||||
@@ -38,6 +39,7 @@ from PySide6.QtWidgets import (
|
|||||||
QGridLayout,
|
QGridLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
|
QInputDialog,
|
||||||
QLabel,
|
QLabel,
|
||||||
QLineEdit,
|
QLineEdit,
|
||||||
QListWidget,
|
QListWidget,
|
||||||
@@ -1686,11 +1688,45 @@ class MainWindow(QMainWindow):
|
|||||||
head.setObjectName("h1")
|
head.setObjectName("h1")
|
||||||
v.addWidget(head)
|
v.addWidget(head)
|
||||||
|
|
||||||
|
search_row = QHBoxLayout()
|
||||||
self.search_box = QLineEdit()
|
self.search_box = QLineEdit()
|
||||||
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
|
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
|
||||||
self.search_box.setClearButtonEnabled(True)
|
self.search_box.setClearButtonEnabled(True)
|
||||||
self.search_box.textChanged.connect(self._on_search_changed)
|
self.search_box.textChanged.connect(self._on_search_changed)
|
||||||
v.addWidget(self.search_box)
|
search_row.addWidget(self.search_box, 1)
|
||||||
|
self.enable_all_btn = QPushButton("All on")
|
||||||
|
self.enable_all_btn.setToolTip("Enable every server")
|
||||||
|
self.enable_all_btn.clicked.connect(lambda: self._set_all_enabled(True))
|
||||||
|
search_row.addWidget(self.enable_all_btn)
|
||||||
|
self.disable_all_btn = QPushButton("All off")
|
||||||
|
self.disable_all_btn.setToolTip("Disable every server")
|
||||||
|
self.disable_all_btn.clicked.connect(lambda: self._set_all_enabled(False))
|
||||||
|
search_row.addWidget(self.disable_all_btn)
|
||||||
|
v.addLayout(search_row)
|
||||||
|
|
||||||
|
# Named server sets (issue #52): apply a saved Active/Disabled split
|
||||||
|
# in one click. Sets live in the config file under _bccServerSets.
|
||||||
|
sets_row = QHBoxLayout()
|
||||||
|
sets_lbl = QLabel("Set")
|
||||||
|
sets_lbl.setObjectName("muted")
|
||||||
|
sets_row.addWidget(sets_lbl)
|
||||||
|
self.sets_combo = QComboBox()
|
||||||
|
self.sets_combo.setMinimumWidth(120)
|
||||||
|
sets_row.addWidget(self.sets_combo, 1)
|
||||||
|
self.apply_set_btn = QPushButton("Apply")
|
||||||
|
self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest")
|
||||||
|
self.apply_set_btn.clicked.connect(self._apply_selected_set)
|
||||||
|
sets_row.addWidget(self.apply_set_btn)
|
||||||
|
self.save_set_btn = QPushButton("Save set…")
|
||||||
|
self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set")
|
||||||
|
self.save_set_btn.clicked.connect(self._save_set)
|
||||||
|
sets_row.addWidget(self.save_set_btn)
|
||||||
|
self.del_set_btn = QPushButton("−")
|
||||||
|
self.del_set_btn.setToolTip("Delete the selected set")
|
||||||
|
self.del_set_btn.setMaximumWidth(32)
|
||||||
|
self.del_set_btn.clicked.connect(self._delete_set)
|
||||||
|
sets_row.addWidget(self.del_set_btn)
|
||||||
|
v.addLayout(sets_row)
|
||||||
|
|
||||||
# Active and Disabled sections live in a vertical splitter so the user
|
# Active and Disabled sections live in a vertical splitter so the user
|
||||||
# can drag the divider instead of being stuck with a fixed-height
|
# can drag the divider instead of being stuck with a fixed-height
|
||||||
@@ -1774,6 +1810,12 @@ class MainWindow(QMainWindow):
|
|||||||
undo_action.setShortcut("Ctrl+Z")
|
undo_action.setShortcut("Ctrl+Z")
|
||||||
undo_action.triggered.connect(self._undo)
|
undo_action.triggered.connect(self._undo)
|
||||||
self.addAction(undo_action)
|
self.addAction(undo_action)
|
||||||
|
# Ctrl+S / Cmd+S shortcut — routed through a guard so it respects
|
||||||
|
# the same dirty/validation gating as the Save button.
|
||||||
|
save_action = QAction(self)
|
||||||
|
save_action.setShortcut(QKeySequence.StandardKey.Save)
|
||||||
|
save_action.triggered.connect(self._save_shortcut)
|
||||||
|
self.addAction(save_action)
|
||||||
bar.addStretch()
|
bar.addStretch()
|
||||||
self.validation_lbl = QLabel("")
|
self.validation_lbl = QLabel("")
|
||||||
bar.addWidget(self.validation_lbl)
|
bar.addWidget(self.validation_lbl)
|
||||||
@@ -1800,6 +1842,19 @@ class MainWindow(QMainWindow):
|
|||||||
self._mark_dirty()
|
self._mark_dirty()
|
||||||
self.status.setText("Undone.")
|
self.status.setText("Undone.")
|
||||||
|
|
||||||
|
def _set_all_enabled(self, enabled: bool):
|
||||||
|
"""Flip every server's enabled flag in one step (one undo snapshot)."""
|
||||||
|
if not self.servers or all(s.enabled == enabled for s in self.servers):
|
||||||
|
return # nothing to change
|
||||||
|
cur = self._current_index()
|
||||||
|
self._push_undo()
|
||||||
|
for s in self.servers:
|
||||||
|
s.enabled = enabled
|
||||||
|
sel = cur if 0 <= cur < len(self.servers) else None
|
||||||
|
self._refresh_tables(select_index=sel)
|
||||||
|
self._mark_dirty()
|
||||||
|
self.status.setText("All servers enabled." if enabled else "All servers disabled.")
|
||||||
|
|
||||||
# --- profiles -------------------------------------------------------- #
|
# --- profiles -------------------------------------------------------- #
|
||||||
def reload_profiles(self):
|
def reload_profiles(self):
|
||||||
discovered = core.discover_profiles()
|
discovered = core.discover_profiles()
|
||||||
@@ -1892,6 +1947,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._undo_stack.clear()
|
self._undo_stack.clear()
|
||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
self._health.clear() # health results are per-profile; a fresh load invalidates them
|
self._health.clear() # health results are per-profile; a fresh load invalidates them
|
||||||
|
self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file
|
||||||
self._refresh_tables(select_index=0 if self.servers else -1)
|
self._refresh_tables(select_index=0 if self.servers else -1)
|
||||||
self._update_status(saved=False)
|
self._update_status(saved=False)
|
||||||
if repaired:
|
if repaired:
|
||||||
@@ -1991,6 +2047,73 @@ class MainWindow(QMainWindow):
|
|||||||
cur = self._current_index()
|
cur = self._current_index()
|
||||||
self._refresh_tables(select_index=cur if cur >= 0 else None)
|
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) ---------------- #
|
# --- test all (spawn-test every enabled local server) ---------------- #
|
||||||
def _test_all_servers(self):
|
def _test_all_servers(self):
|
||||||
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
|
||||||
@@ -2309,11 +2432,23 @@ class MainWindow(QMainWindow):
|
|||||||
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
self.validation_lbl.setStyleSheet(f"color: {WARN};")
|
||||||
self.save_btn.setEnabled(False)
|
self.save_btn.setEnabled(False)
|
||||||
return False
|
return False
|
||||||
self.validation_lbl.setText("✓ valid")
|
lint_warnings = core.lint_servers(self.servers)
|
||||||
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
|
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)
|
self.save_btn.setEnabled(self.dirty)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _save_shortcut(self):
|
||||||
|
"""Ctrl+S / Cmd+S handler — only fires when the Save button itself
|
||||||
|
would accept a click, so the shortcut can't bypass validation/dirty
|
||||||
|
gating."""
|
||||||
|
if self.save_btn.isEnabled():
|
||||||
|
self.save()
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
if not self.current_profile:
|
if not self.current_profile:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.2.1",
|
"CFBundleShortVersionString": "1.3.0",
|
||||||
"CFBundleVersion": "1.2.1",
|
"CFBundleVersion": "1.3.0",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+172
-2
@@ -39,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json"
|
|||||||
# we can toggle it back on without losing the definition.
|
# we can toggle it back on without losing the definition.
|
||||||
DISABLED_KEY = "_disabledMcpServers"
|
DISABLED_KEY = "_disabledMcpServers"
|
||||||
|
|
||||||
|
# Named server sets: {set_name: [enabled server names]}. Same pattern as
|
||||||
|
# DISABLED_KEY — a bcc-owned key Claude ignores, stored in the config file so
|
||||||
|
# sets travel with it. Applying a set enables exactly the listed servers and
|
||||||
|
# parks the rest under DISABLED_KEY.
|
||||||
|
SETS_KEY = "_bccServerSets"
|
||||||
|
|
||||||
BACKUP_DIRNAME = ".bcc_backups"
|
BACKUP_DIRNAME = ".bcc_backups"
|
||||||
MAX_BACKUPS = 15
|
MAX_BACKUPS = 15
|
||||||
|
|
||||||
@@ -59,7 +65,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
|
|||||||
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
# binary. All network I/O here is fail-quiet (returns None on any problem)
|
||||||
# so it's safe to run unattended, off the UI thread, at startup.
|
# so it's safe to run unattended, off the UI thread, at startup.
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
__version__ = "1.2.1"
|
__version__ = "1.3.0"
|
||||||
|
|
||||||
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
|
||||||
ISSUES_URL = f"{REPO_URL}/issues"
|
ISSUES_URL = f"{REPO_URL}/issues"
|
||||||
@@ -282,10 +288,43 @@ def msix_warning_text(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||||
|
"""
|
||||||
|
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||||
|
|
||||||
|
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||||
|
directory path (that's what the CLI writes as it's used in each repo).
|
||||||
|
Any project whose directory has a `.mcp.json` file next to it -- a
|
||||||
|
standalone file with a top-level `mcpServers` object, same shape BCC
|
||||||
|
already edits -- is surfaced here as its own profile so it can be opened
|
||||||
|
via 'Add config...' without hunting for the path by hand.
|
||||||
|
|
||||||
|
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||||
|
`projects` value that isn't a dict, just yields an empty list rather than
|
||||||
|
raising -- this is best-effort discovery, not a required config load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg = load_config(claude_json_path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
projects = cfg.get("projects")
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
return []
|
||||||
|
out: list[Profile] = []
|
||||||
|
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||||
|
mcp_path = Path(key) / ".mcp.json"
|
||||||
|
if mcp_path.is_file():
|
||||||
|
out.append(
|
||||||
|
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def discover_profiles() -> list[Profile]:
|
def discover_profiles() -> list[Profile]:
|
||||||
"""
|
"""
|
||||||
Find every `Claude*` data directory in the platform's app-support base
|
Find every `Claude*` data directory in the platform's app-support base
|
||||||
(Claude Desktop installs), then also check for a Claude Code global config.
|
(Claude Desktop installs), then also check for a Claude Code global config
|
||||||
|
and any project-scope `.mcp.json` configs it knows about.
|
||||||
|
|
||||||
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
Claude Desktop: scans the platform app-support folder for any `Claude*`
|
||||||
directory (catches `Claude`, `Claude-Work`, etc.).
|
directory (catches `Claude`, `Claude-Work`, etc.).
|
||||||
@@ -298,6 +337,10 @@ def discover_profiles() -> list[Profile]:
|
|||||||
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
`claude mcp add` writes; project scope is a per-repo .mcp.json, which can
|
||||||
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
|
||||||
for permissions/hooks and rejects an mcpServers key with a schema error.
|
for permissions/hooks and rejects an mcpServers key with a schema error.
|
||||||
|
Project scope: ~/.claude.json also tracks a `projects` map, one entry per
|
||||||
|
directory Claude Code has been run in; any of those with a `.mcp.json`
|
||||||
|
file are surfaced as their own profiles too (see
|
||||||
|
`discover_project_configs`).
|
||||||
"""
|
"""
|
||||||
base = app_support_base()
|
base = app_support_base()
|
||||||
out: list[Profile] = []
|
out: list[Profile] = []
|
||||||
@@ -319,6 +362,12 @@ def discover_profiles() -> list[Profile]:
|
|||||||
cc_cfg = home / ".claude.json"
|
cc_cfg = home / ".claude.json"
|
||||||
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
|
||||||
|
|
||||||
|
existing_paths = {str(p.path) for p in out}
|
||||||
|
for proj in discover_project_configs(cc_cfg):
|
||||||
|
if str(proj.path) not in existing_paths:
|
||||||
|
existing_paths.add(str(proj.path))
|
||||||
|
out.append(proj)
|
||||||
|
|
||||||
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
||||||
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
||||||
# file only when it actually contains an mcpServers block, so the user can
|
# file only when it actually contains an mcpServers block, so the user can
|
||||||
@@ -409,6 +458,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Named server sets (issue #52)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def list_server_sets(cfg: dict) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Return {set_name: [enabled server names]} from cfg's SETS_KEY.
|
||||||
|
|
||||||
|
Fail-soft: entries whose value isn't a list of strings (hand-edited or
|
||||||
|
corrupted) are skipped rather than raising, so one bad set never hides
|
||||||
|
the rest.
|
||||||
|
"""
|
||||||
|
raw = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for name, members in raw.items():
|
||||||
|
if isinstance(members, list) and all(isinstance(m, str) for m in members):
|
||||||
|
out[str(name)] = list(members)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_server_set(cfg: dict, name: str, servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Snapshot the current enabled-server names into cfg under SETS_KEY as
|
||||||
|
`name` (overwriting an existing set of that name). Returns the saved
|
||||||
|
member list. The caller decides when cfg reaches disk (normal Save flow).
|
||||||
|
"""
|
||||||
|
members = [s.name for s in servers if s.enabled]
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict):
|
||||||
|
sets = {}
|
||||||
|
cfg[SETS_KEY] = sets
|
||||||
|
sets[name] = members
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def delete_server_set(cfg: dict, name: str) -> bool:
|
||||||
|
"""Remove set `name` from cfg. Drops SETS_KEY entirely when the last set
|
||||||
|
goes, so untouched configs don't grow an empty bcc key. Returns True if
|
||||||
|
something was deleted."""
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict) or name not in sets:
|
||||||
|
return False
|
||||||
|
del sets[name]
|
||||||
|
if not sets:
|
||||||
|
cfg.pop(SETS_KEY, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_server_set(servers: list[ServerEntry], enabled_names: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Enable exactly the servers named in `enabled_names`; disable every other
|
||||||
|
entry (in place). Returns the set members that no longer exist in
|
||||||
|
`servers` — the caller surfaces those as a warning, and the rest of the
|
||||||
|
set still applies.
|
||||||
|
"""
|
||||||
|
wanted = set(enabled_names)
|
||||||
|
present: set[str] = set()
|
||||||
|
for s in servers:
|
||||||
|
s.enabled = s.name in wanted
|
||||||
|
present.add(s.name)
|
||||||
|
return sorted(wanted - present)
|
||||||
|
|
||||||
|
|
||||||
def resolve_name_collision(name: str, existing: set[str]) -> str:
|
def resolve_name_collision(name: str, existing: set[str]) -> str:
|
||||||
"""
|
"""
|
||||||
Return a name guaranteed not to collide with `existing`.
|
Return a name guaranteed not to collide with `existing`.
|
||||||
@@ -1213,6 +1326,63 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
|||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def lint_server(name: str, data: dict) -> list[str]:
|
||||||
|
"""Return non-blocking structural warnings for a single server definition.
|
||||||
|
|
||||||
|
Unlike validate_servers, nothing here blocks Save -- these are advisory
|
||||||
|
notes about shapes that will round-trip through JSON fine but are
|
||||||
|
probably not what the user intended (args given as a plain string
|
||||||
|
instead of a list, an env value that isn't a string, an unrecognized
|
||||||
|
`type`, unknown top-level fields, etc.).
|
||||||
|
"""
|
||||||
|
nm = name.strip() or "(unnamed)"
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if "command" in data and not isinstance(data["command"], str):
|
||||||
|
warnings.append(f"'{nm}': 'command' should be a string")
|
||||||
|
|
||||||
|
if "args" in data:
|
||||||
|
args = data["args"]
|
||||||
|
if not isinstance(args, list):
|
||||||
|
warnings.append(f"'{nm}': 'args' should be a list (one argument per item)")
|
||||||
|
elif any(not isinstance(a, str) for a in args):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': 'args' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("env", "headers"):
|
||||||
|
if field not in data:
|
||||||
|
continue
|
||||||
|
val = data[field]
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs")
|
||||||
|
elif any(not isinstance(v, str) for v in val.values()):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': '{field}' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "type" in data:
|
||||||
|
t = data["type"]
|
||||||
|
if t not in ("http", "sse", "stdio"):
|
||||||
|
warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})")
|
||||||
|
|
||||||
|
extra = sorted(k for k in data if k not in KNOWN_FIELDS)
|
||||||
|
if extra:
|
||||||
|
warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def lint_servers(servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""Concatenate lint_server warnings across every entry, in order."""
|
||||||
|
out: list[str] = []
|
||||||
|
for s in servers:
|
||||||
|
out.extend(lint_server(s.name, s.data))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
# Search / filter
|
# Search / filter
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.2.1"
|
version = "1.3.0"
|
||||||
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
|
|||||||
@@ -67,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())
|
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
|
# 2. Write pipeline: preserves other keys + order, only touches mcpServers
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -181,6 +239,85 @@ def test_valid_set_passes_clean():
|
|||||||
assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == []
|
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
|
# 5. Secrets: detection + redaction
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -769,6 +906,68 @@ def test_args_secret_warning_empty():
|
|||||||
assert c.args_secret_warning({"args": []}) is None
|
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)
|
# resolve_name_collision (paste/import duplicate-name handling — issue #8)
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user