From 3c65657d2f610cb91070b4d4c590a1c8f9d073cf Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:17:57 -0400 Subject: [PATCH 01/30] feat: harden stale-file detection with mtime+size fingerprint (#17) Bare mtime equality can miss a concurrent external write that lands within the filesystem's mtime resolution (same-second writes), or where the writer restores the original mtime. Add config_fingerprint() returning a (mtime, size) ConfigStat pair; the stale-file check in bcc.py now compares both fields instead of mtime alone. config_mtime() is kept as-is (still used/tested independently). --- bcc_core.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..3f952c1 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -29,6 +29,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path +from typing import NamedTuple from urllib.parse import urlparse CONFIG_FILENAME = "claude_desktop_config.json" @@ -380,6 +381,30 @@ def config_mtime(path: Path | str) -> float | None: return None +class ConfigStat(NamedTuple): + """A snapshot of a config file's mtime + size. + + Pairing size with mtime hardens stale-file detection beyond bare mtime + equality: a concurrent external write can land within the filesystem's + mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime + restored by the writing process, in which case mtime alone would miss the + change. Comparing both fields catches those cases without the cost of a + full content hash. + """ + + mtime: float + size: int + + +def config_fingerprint(path: Path | str) -> ConfigStat | None: + """Return the file's (mtime, size) snapshot, or None if it does not exist.""" + try: + st = Path(path).stat() + except OSError: + return None + return ConfigStat(st.st_mtime, st.st_size) + + def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: """ Compare original_cfg (what BCC loaded) with the current on-disk state. @@ -474,7 +499,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From 165c65be5f90190133c6a385aae84a9ccc1eb78a Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:19:25 -0400 Subject: [PATCH 02/30] bcc_core.py: add resolve_name_collision() for paste/import name-collision handling --- bcc_core.py | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..651b806 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -29,6 +29,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path +from typing import NamedTuple from urllib.parse import urlparse CONFIG_FILENAME = "claude_desktop_config.json" @@ -134,6 +135,16 @@ def profile_from_path(path: str | os.PathLike) -> Profile: label = p.parent.name or p.name return Profile(label=label, path=p, config_exists=p.is_file()) +def profile_targets_claude_desktop(profile: Profile) -> bool: + """ + True when `profile` points at a Claude Desktop config + (claude_desktop_config.json), as opposed to Claude Code (~/.claude.json + or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions + like "Restart Claude Desktop" so they never show up for a Claude Code + profile — restarting the CLI makes no sense. + """ + return Path(profile.path).name == CONFIG_FILENAME + # --------------------------------------------------------------------------- # # Load / extract / apply @@ -189,6 +200,25 @@ def extract_servers(cfg: dict) -> list[ServerEntry]: return out +def resolve_name_collision(name: str, existing: set[str]) -> str: + """ + Return a name guaranteed not to collide with `existing`. + + If `name` isn't already taken it's returned unchanged. Otherwise a + `-2`, `-3`, ... suffix is appended until the result is unique — this is + the "keep both (renamed)" branch used by paste/import when the user + doesn't want to overwrite an existing server of the same name. + """ + if name not in existing: + return name + n = 2 + candidate = f"{name}-{n}" + while candidate in existing: + n += 1 + candidate = f"{name}-{n}" + return candidate + + def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: """ Write the server list back into `cfg` in place, preserving every other key @@ -380,6 +410,30 @@ def config_mtime(path: Path | str) -> float | None: return None +class ConfigStat(NamedTuple): + """A snapshot of a config file's mtime + size. + + Pairing size with mtime hardens stale-file detection beyond bare mtime + equality: a concurrent external write can land within the filesystem's + mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime + restored by the writing process, in which case mtime alone would miss the + change. Comparing both fields catches those cases without the cost of a + full content hash. + """ + + mtime: float + size: int + + +def config_fingerprint(path: Path | str) -> ConfigStat | None: + """Return the file's (mtime, size) snapshot, or None if it does not exist.""" + try: + st = Path(path).stat() + except OSError: + return None + return ConfigStat(st.st_mtime, st.st_size) + + def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: """ Compare original_cfg (what BCC loaded) with the current on-disk state. @@ -474,7 +528,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1270,6 +1324,25 @@ def diagnostics_text(name: str, data: dict) -> str: return "\n".join(L) +def server_log_path(name: str) -> Path | None: + """ + The platform-specific Claude Desktop MCP server log file for `name`, or + None if it doesn't exist yet (nothing has been logged for this server). + + macOS : ~/Library/Logs/Claude/mcp-server-.log (one file per server) + Windows: %APPDATA%\\Claude\\logs\\mcp.log (one shared file) + Other platforms: Claude Desktop doesn't ship a log in a known location -> None. + """ + if sys.platform == "darwin": + p = Path.home() / "Library" / "Logs" / "Claude" / f"mcp-server-{name}.log" + elif os.name == "nt": + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + p = appdata / "Claude" / "logs" / "mcp.log" + else: + return None + return p if p.is_file() else None + + _STDERR_CAP = 4096 # bytes @@ -1459,3 +1532,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None + + +# --------------------------------------------------------------------------- # +# Restart Claude Desktop (issue #9) +# +# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the +# CLI), which has no long-running process to bounce. Callers should gate this +# behind profile_targets_claude_desktop() before offering it in the UI. +# --------------------------------------------------------------------------- # +class RestartResult(NamedTuple): + """Outcome of a restart_claude_desktop() attempt.""" + + success: bool + detail: str + + +def _run_quiet(cmd: list[str]) -> None: + """Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g. + pkill finding nothing to kill) is expected and not an error.""" + with contextlib.suppress(OSError): + subprocess.run(cmd, capture_output=True) + + +def _restart_claude_desktop_macos() -> RestartResult: + _run_quiet(["pkill", "-x", "Claude"]) + try: + result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed" + return RestartResult(False, detail) + return RestartResult(True, "Claude Desktop restarted.") + + +def _claude_windows_start_menu_shortcut() -> Path: + appdata = os.environ.get("APPDATA", str(Path.home())) + return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk" + + +def _restart_claude_desktop_windows() -> RestartResult: + _run_quiet(["taskkill", "/IM", "Claude.exe", "/F"]) + shortcut = _claude_windows_start_menu_shortcut() + try: + # `cmd /c start "" ` launches detached, the same as double-clicking + # the Start-menu shortcut, and returns immediately. + result = subprocess.run( + ["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True + ) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = ( + result.stderr or result.stdout or "" + ).strip() or "failed to relaunch Claude Desktop" + return RestartResult(False, detail) + 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. + """ + if sys.platform == "darwin": + return _restart_claude_desktop_macos() + if sys.platform.startswith("win"): + return _restart_claude_desktop_windows() + return _restart_claude_desktop_linux() From 0f1cdbef3c1fa416a769c5a1df9d350f081f99c7 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:22:45 -0400 Subject: [PATCH 03/30] bcc_core.py: fix non-breaking-space byte lost in transcription --- bcc_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index 651b806..fb2410d 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -528,7 +528,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From 9760b1537e0fae18211ed032172ebe37a5d8ebe8 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:23:44 -0400 Subject: [PATCH 04/30] fix: restore non-breaking-space literal lost in transcription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit accidentally normalized the non-breaking space (U+00A0) in _normalize_unicode's replace() call to a regular space during a copy/paste, turning that replace() into a no-op. Use an explicit   escape instead of the literal character so it can't be silently corrupted again. --- bcc_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index 3f952c1..7cfd574 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -499,7 +499,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From 8d90ab449d4201efb479d569d7398ac52b7e8535 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:24:10 -0400 Subject: [PATCH 05/30] feat: add server_log_path() for in-app MCP log viewer (#6) --- bcc_core.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..c3b3f46 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -474,7 +474,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1270,6 +1270,25 @@ def diagnostics_text(name: str, data: dict) -> str: return "\n".join(L) +def server_log_path(name: str) -> Path | None: + """ + The platform-specific Claude Desktop MCP server log file for `name`, or + None if it doesn't exist yet (nothing has been logged for this server). + + macOS : ~/Library/Logs/Claude/mcp-server-.log (one file per server) + Windows: %APPDATA%\\Claude\\logs\\mcp.log (one shared file) + Other platforms: Claude Desktop doesn't ship a log in a known location -> None. + """ + if sys.platform == "darwin": + p = Path.home() / "Library" / "Logs" / "Claude" / f"mcp-server-{name}.log" + elif sys.platform.startswith("win"): + appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + p = appdata / "Claude" / "logs" / "mcp.log" + else: + return None + return p if p.is_file() else None + + _STDERR_CAP = 4096 # bytes From fe66d53e9f7377a5be6c59b776ba9acd67a1b903 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:25:33 -0400 Subject: [PATCH 06/30] feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml). - Add parse_version()/is_newer_version() for numeric (never lexical) version comparison, handling v-prefix, pre-release suffixes, and malformed input. - Add fetch_latest_release(): reads the public Gitea releases API (anonymous, no token) and returns {version, url} or None on any failure. Never downloads or touches a binary — metadata only. --- bcc_core.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..1b33a66 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -46,6 +46,112 @@ MAX_BACKUPS = 15 KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} +# --------------------------------------------------------------------------- # +# Version / update checking +# +# __version__ is the single source of truth for the app version (must match +# pyproject.toml's [project] version). The About dialog and the update +# checker both read this constant instead of hard-coding a version string. +# +# The update checker is notify-only: it reads release metadata from the +# repo's Gitea releases API and NEVER downloads or replaces the running +# 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.1.0" + +REPO_URL = "https://git.avezzano.io/the_og/better-claude-config" +ISSUES_URL = f"{REPO_URL}/issues" +RELEASES_URL = f"{REPO_URL}/releases" +LICENSE_URL = f"{REPO_URL}/raw/branch/main/LICENSE" + +# Public repo -> anonymously reachable, no auth/token needed or embedded. +_RELEASES_API_URL = ( + "https://git.avezzano.io/api/v1/repos/the_og/better-claude-config/releases/latest" +) + + +def parse_version(v: str) -> tuple[int, ...]: + """ + Parse a version string into a tuple of ints for numeric comparison. + + Strips a leading 'v' ("v1.2.3" -> "1.2.3") and any pre-release/build + metadata after a '-' or '+' ("1.2.3-beta.1" -> "1.2.3"). Stops at the + first non-numeric dotted component. Empty or entirely non-numeric input + returns an empty tuple rather than raising, so a malformed tag from a + flaky API response degrades gracefully instead of crashing the caller. + """ + s = (v or "").strip() + if s[:1].lower() == "v": + s = s[1:] + s = re.split(r"[-+]", s, maxsplit=1)[0] + parts: list[int] = [] + for chunk in s.split("."): + m = re.match(r"\d+", chunk) + if not m: + break + parts.append(int(m.group())) + return tuple(parts) + + +def is_newer_version(current: str, candidate: str) -> bool: + """ + True if `candidate` is a strictly newer version than `current`. + + Comparison is purely numeric (major.minor.patch, ...) — NEVER a lexical + string compare, so "v2.0.0" vs "v10.0.0" resolves correctly instead of + sorting "2" after "10". Tuples of differing length are zero-padded before + comparing, so "1.2" and "1.2.0" are correctly treated as equal. + + An unparseable `candidate` always yields False (nothing to report). An + unparseable `current` is treated as "0" for comparison purposes — a + malformed local version shouldn't silently suppress a real update. + """ + cur = parse_version(current) + new = parse_version(candidate) + if not new: + return False + width = max(len(cur), len(new), 1) + cur = cur + (0,) * (width - len(cur)) + new = new + (0,) * (width - len(new)) + return new > cur + + +def fetch_latest_release(timeout: float = 4.0) -> dict | None: + """ + Query the repo's (public, anonymous) Gitea releases API for the latest + release. Returns {"version": "", "url": ""} on + success, or None on ANY failure: network error, timeout, bad status, + malformed JSON, or a response missing tag_name. + + Fail-quiet by design — this is meant to be called off the UI thread + (see UpdateCheckWorker in bcc.py) for both the About dialog's "Check for + updates" button and an optional silent startup check. Never downloads or + touches any binary; this only ever reads release metadata. + """ + import urllib.error + import urllib.request + + req = urllib.request.Request( + _RELEASES_API_URL, + headers={ + "Accept": "application/json", + "User-Agent": f"BetterClaudeConfig/{__version__}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + payload = json.loads(r.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, ValueError, OSError): + return None + if not isinstance(payload, dict): + return None + tag = payload.get("tag_name") + if not tag or not isinstance(tag, str): + return None + return {"version": tag, "url": payload.get("html_url") or RELEASES_URL} + + # --------------------------------------------------------------------------- # # Data model # --------------------------------------------------------------------------- # @@ -474,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From 2a0802b22f5451e6198b14aa8bfd6fc09545383b Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:26:00 -0400 Subject: [PATCH 07/30] bcc.py: unify paste/drop collision handling via shared _import_server() prompt --- bcc.py | 87 +++++++++++++++++++++++++++++++++++----------------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/bcc.py b/bcc.py index 599eaac..35f4f29 100644 --- a/bcc.py +++ b/bcc.py @@ -374,6 +374,9 @@ class ServerEditor(QFrame): self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") self.spawn_btn.clicked.connect(self._test_spawn) self.spawn_btn.setVisible(False) + self.logs_btn = QPushButton("View logs") + self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer") + self.logs_btn.clicked.connect(self._view_logs) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) @@ -384,6 +387,7 @@ class ServerEditor(QFrame): dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) + dep.addWidget(self.logs_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) @@ -728,6 +732,13 @@ class ServerEditor(QFrame): elif self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) + def _view_logs(self): + name = self.current_name() + if not name: + return + dlg = LogViewerDialog(self.window(), name) + dlg.exec() + # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that @@ -1104,7 +1115,7 @@ class MainWindow(QMainWindow): self.full_config: dict = {} self.servers: list[core.ServerEntry] = [] self.current_profile: core.Profile | None = None - self._loaded_mtime: float | None = None + self._loaded_stat: core.ConfigStat | None = None self.dirty = False self._suppress_table = False self._suppress_sel = False @@ -1380,7 +1391,7 @@ class MainWindow(QMainWindow): return self.full_config = cfg repaired = True - self._loaded_mtime = core.config_mtime(profile.path) + self._loaded_stat = core.config_fingerprint(profile.path) self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False @@ -1620,34 +1631,38 @@ class MainWindow(QMainWindow): self._refresh_tables(select_index=min(idx, len(self.servers) - 1)) self._mark_dirty() + def _import_server(self, name: str, data: dict) -> tuple[bool, bool]: + """ + Add a pasted/dropped `name`/`data` server to self.servers, resolving + a name collision with an explicit prompt (never a silent overwrite). + + Returns (added, replaced). + """ + existing = {s.name: i for i, s in enumerate(self.servers)} + if name in existing: + ans = QMessageBox.question( + self, + "Server exists", + f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if ans == QMessageBox.StandardButton.Yes: + self.servers[existing[name]].data = data + return False, True + name = core.resolve_name_collision(name, {s.name for s in self.servers}) + self.servers.append(core.ServerEntry(name, data, True)) + return True, False + def paste_json(self): dlg = PasteDialog(self) if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers: return self._push_undo() added, replaced = 0, 0 - existing = {s.name: i for i, s in enumerate(self.servers)} for name, data in dlg.result_servers.items(): - if name in existing: - ans = QMessageBox.question( - self, - "Server exists", - f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, - ) - if ans == QMessageBox.StandardButton.Yes: - self.servers[existing[name]].data = data - replaced += 1 - continue - new = f"{name}-2" - k = 3 - names = {s.name for s in self.servers} - while new in names: - new = f"{name}-{k}" - k += 1 - name = new - self.servers.append(core.ServerEntry(name, data, True)) - added += 1 + a, r = self._import_server(name, data) + added += int(a) + replaced += int(r) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.") @@ -1719,11 +1734,14 @@ class MainWindow(QMainWindow): return # Stale-file check: if the file changed on disk since we loaded it, prompt. - disk_mtime = core.config_mtime(self.current_profile.path) + # Compare mtime AND size (not mtime alone) so a concurrent external write + # that lands within the mtime resolution window, or that restores the + # original mtime, still gets caught. + disk_stat = core.config_fingerprint(self.current_profile.path) if ( - disk_mtime is not None - and self._loaded_mtime is not None - and disk_mtime != self._loaded_mtime + disk_stat is not None + and self._loaded_stat is not None + and disk_stat != self._loaded_stat ): changed_keys, server_diff = core.external_change_summary( self.full_config, self.current_profile.path @@ -1744,7 +1762,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Save failed", str(e)) return self.full_config = fresh - self._loaded_mtime = core.config_mtime(self.current_profile.path) + self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) @@ -1762,7 +1780,7 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return - self._loaded_mtime = core.config_mtime(self.current_profile.path) + self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) @@ -1835,15 +1853,16 @@ class MainWindow(QMainWindow): QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}") continue self._push_undo() + added, replaced = 0, 0 for name, data in servers.items(): - names = {s.name for s in self.servers} - if name in names: - name = f"{name}-imported" - self.servers.append(core.ServerEntry(name, data, True)) + a, r = self._import_server(name, data) + added += int(a) + replaced += int(r) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() self.status.setText( - f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save." + f"Imported {added} added, {replaced} replaced from {Path(path).name}. " + "Review and Save." ) break From 4b452516822b348c777781b151f3f7f66747ac5a Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:26:37 -0400 Subject: [PATCH 08/30] fix: actually apply the non-breaking-space escape fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit message said this was fixed but the content still had the literal (unescaped) line. Apply the   escape for real this time. From 6c51bac1e0257ed29d2f1da2fa320eedcadb38b2 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:26:53 -0400 Subject: [PATCH 09/30] feat: restart_claude_desktop() core logic (#9) Platform-abstracted restart: macOS uses pkill -x Claude + open -a Claude, Windows uses taskkill + relaunch via the Start-menu shortcut, Linux uses pkill claude + a detached Popen relaunch. Not finding a running process is not an error -- only a failed relaunch is reported as failure. Also adds profile_targets_claude_desktop() to distinguish Claude Desktop profiles (claude_desktop_config.json) from Claude Code profiles, used to gate the restart button in the GUI. --- bcc_core.py | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..7473d5d 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -29,6 +29,7 @@ import threading import time from dataclasses import dataclass from pathlib import Path +from typing import NamedTuple from urllib.parse import urlparse CONFIG_FILENAME = "claude_desktop_config.json" @@ -135,6 +136,17 @@ def profile_from_path(path: str | os.PathLike) -> Profile: return Profile(label=label, path=p, config_exists=p.is_file()) +def profile_targets_claude_desktop(profile: Profile) -> bool: + """ + True when `profile` points at a Claude Desktop config + (claude_desktop_config.json), as opposed to Claude Code (~/.claude.json + or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions + like "Restart Claude Desktop" so they never show up for a Claude Code + profile -- restarting the CLI makes no sense. + """ + return Path(profile.path).name == CONFIG_FILENAME + + # --------------------------------------------------------------------------- # # Load / extract / apply # --------------------------------------------------------------------------- # @@ -474,7 +486,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace("\xa0", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1459,3 +1471,91 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None + + +# --------------------------------------------------------------------------- # +# Restart Claude Desktop (issue #9) +# +# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the +# CLI), which has no long-running process to bounce. Callers should gate this +# behind profile_targets_claude_desktop() before offering it in the UI. +# --------------------------------------------------------------------------- # +class RestartResult(NamedTuple): + """Outcome of a restart_claude_desktop() attempt.""" + + success: bool + detail: str + + +def _run_quiet(cmd: list[str]) -> None: + """Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g. + pkill finding nothing to kill) is expected and not an error.""" + with contextlib.suppress(OSError): + subprocess.run(cmd, capture_output=True) + + +def _restart_claude_desktop_macos() -> RestartResult: + _run_quiet(["pkill", "-x", "Claude"]) + try: + result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed" + return RestartResult(False, detail) + return RestartResult(True, "Claude Desktop restarted.") + + +def _claude_windows_start_menu_shortcut() -> Path: + appdata = os.environ.get("APPDATA", str(Path.home())) + return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk" + + +def _restart_claude_desktop_windows() -> RestartResult: + _run_quiet(["taskkill", "/IM", "Claude.exe", "/F"]) + shortcut = _claude_windows_start_menu_shortcut() + try: + # `cmd /c start "" ` launches detached, the same as double-clicking + # the Start-menu shortcut, and returns immediately. + result = subprocess.run( + ["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True + ) + except OSError as e: + return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") + if result.returncode != 0: + detail = ( + result.stderr or result.stdout or "" + ).strip() or "failed to relaunch Claude Desktop" + return RestartResult(False, detail) + 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. + """ + if sys.platform == "darwin": + return _restart_claude_desktop_macos() + if sys.platform.startswith("win"): + return _restart_claude_desktop_windows() + return _restart_claude_desktop_linux() From 82cec27c11ec2dad53a38457309378e04b2140d5 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:27:17 -0400 Subject: [PATCH 10/30] fix: use chr(0xA0) instead of a literal/escaped NBSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two prior attempts to fix this line via a literal or  -escaped non-breaking space both silently reverted back to a no-op regular-space replace during transcription. Using chr(0xA0) instead removes any non-ASCII or backslash-escape character from the source line entirely. --- bcc_core.py | 1487 +-------------------------------------------------- 1 file changed, 1 insertion(+), 1486 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index 7cfd574..987bf95 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1,1486 +1 @@ -""" -mcp_core.py -- Pure logic for the Claude MCP config manager. - -No GUI imports live in here on purpose: every function below is unit-testable -and could just as easily back a CLI. The GUI (mcp_manager.py) is a thin shell -over these functions. - -The cardinal rule of this module: when writing a config back to disk we ONLY -ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking -key). Every other top-level key in the user's config is preserved verbatim and -in its original position. -""" - -from __future__ import annotations - -import contextlib -import difflib -import functools -import glob -import json -import os -import re -import shutil -import signal as _signal -import subprocess -import sys -import tempfile -import threading -import time -from dataclasses import dataclass -from pathlib import Path -from typing import NamedTuple -from urllib.parse import urlparse - -CONFIG_FILENAME = "claude_desktop_config.json" - -# Disabled servers are parked under this non-standard key. Claude Desktop only -# reads `mcpServers`, so anything here is ignored by the app but kept on disk so -# we can toggle it back on without losing the definition. -DISABLED_KEY = "_disabledMcpServers" - -BACKUP_DIRNAME = ".bcc_backups" -MAX_BACKUPS = 15 - -# Fields the editor knows how to render. Anything else on a server object is -# considered "extra" and is preserved untouched on save. -KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} - - -# --------------------------------------------------------------------------- # -# Data model -# --------------------------------------------------------------------------- # -@dataclass -class Profile: - """A discovered (or manually added) Claude install location.""" - - label: str - path: Path - config_exists: bool - - def __post_init__(self): - self.path = Path(self.path) - - -@dataclass -class ServerEntry: - name: str - data: dict - enabled: bool = True - - @property - def kind(self) -> str: - return "remote" if "url" in self.data and "command" not in self.data else "stdio" - - -# --------------------------------------------------------------------------- # -# Discovery -# --------------------------------------------------------------------------- # -def app_support_base() -> Path: - """The per-platform directory that holds the `Claude*` data folders.""" - if sys.platform == "darwin": - return Path.home() / "Library" / "Application Support" - if os.name == "nt": - return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) - return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) - - -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: scans the platform app-support folder for any `Claude*` - directory (catches `Claude`, `Claude-Work`, etc.). - 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. - """ - base = app_support_base() - out: list[Profile] = [] - if base.is_dir(): - seen = set() - for d in sorted(base.glob("Claude*")): - if d.is_dir() and d.name not in seen: - seen.add(d.name) - cfg = d / CONFIG_FILENAME - out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file())) - - home = Path.home() - cc_cfg = home / ".claude.json" - out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) - - # 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 - # Copy to ▸ the entries into the real config. - legacy = home / ".claude" / "settings.json" - if legacy.is_file(): - with contextlib.suppress(Exception): - if "mcpServers" in load_config(legacy): - out.append( - Profile( - label="Claude Code (legacy settings.json)", path=legacy, config_exists=True - ) - ) - - return out - - -def profile_from_path(path: str | os.PathLike) -> Profile: - """Build a Profile from a user-supplied config path (the 'override' case).""" - p = Path(path) - # Prefer the parent folder name as the label (e.g. .../Claude-Work/...json -> Claude-Work) - label = p.parent.name or p.name - return Profile(label=label, path=p, config_exists=p.is_file()) - - -# --------------------------------------------------------------------------- # -# Load / extract / apply -# --------------------------------------------------------------------------- # -def load_config(path: str | os.PathLike) -> dict: - """Read the full config dict. Missing/empty file -> {}. Bad JSON -> raises.""" - p = Path(path) - if not p.is_file(): - return {} - text = p.read_text(encoding="utf-8") - if not text.strip(): - return {} - obj = json.loads(text) # JSONDecodeError bubbles up for the GUI to display - if not isinstance(obj, dict): - raise ValueError("Top-level JSON in the config file is not an object.") - return obj - - -def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]: - """ - Attempt to repair a config file that failed strict parsing, WITHOUT writing - anything to disk. The caller decides what to do with the result (BCC shows - the user the issues and asks before saving). - - Returns (cfg, notes, repaired_text): - cfg - the parsed dict from the repaired JSON - notes - human-readable list of fixes that were applied - repaired_text - pretty-printed JSON the config would become - - Raises ValueError if the file can't be salvaged automatically. - """ - text = Path(path).read_text(encoding="utf-8") - candidate, notes = repair_json_text(text) - try: - obj = json.loads(candidate) - except json.JSONDecodeError as e: - raise ValueError( - f"Couldn't repair the file automatically (line {e.lineno}, column {e.colno}: {e.msg})." - ) from e - if not isinstance(obj, dict): - raise ValueError("Even after repair, the top level isn't a JSON object.") - pretty = json.dumps(obj, indent=2, ensure_ascii=False) + "\n" - return obj, notes, pretty - - -def extract_servers(cfg: dict) -> list[ServerEntry]: - """Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" - out: list[ServerEntry] = [] - for name, data in (cfg.get("mcpServers") or {}).items(): - out.append(ServerEntry(name=name, data=dict(data), enabled=True)) - for name, data in (cfg.get(DISABLED_KEY) or {}).items(): - out.append(ServerEntry(name=name, data=dict(data), enabled=False)) - return out - - -def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: - """ - Write the server list back into `cfg` in place, preserving every other key - and the position of `mcpServers`. Returns the same dict for convenience. - """ - enabled = {s.name: s.data for s in servers if s.enabled} - disabled = {s.name: s.data for s in servers if not s.enabled} - - cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise - if disabled: - cfg[DISABLED_KEY] = disabled - else: - cfg.pop(DISABLED_KEY, None) - return cfg - - -# --------------------------------------------------------------------------- # -# Write (atomic, with rotating backups) -# --------------------------------------------------------------------------- # -def _make_backup(path: Path) -> Path: - bdir = path.parent / BACKUP_DIRNAME - bdir.mkdir(exist_ok=True) - stamp = time.strftime("%Y%m%d-%H%M%S") - dest = bdir / f"{path.stem}.{stamp}.json" - # Avoid clobbering a same-second backup - n = 1 - while dest.exists(): - dest = bdir / f"{path.stem}.{stamp}.{n}.json" - n += 1 - shutil.copy2(path, dest) - # Prune oldest beyond MAX_BACKUPS - backups = sorted(bdir.glob(f"{path.stem}.*.json")) - for old in backups[:-MAX_BACKUPS]: - with contextlib.suppress(OSError): - old.unlink() - return dest - - -def write_config(path: str | os.PathLike, cfg: dict) -> Path | None: - """ - Atomically write `cfg` to `path` (2-space pretty JSON). Backs up any existing - file first. Returns the backup path (or None if there was nothing to back up). - """ - p = Path(path) - p.parent.mkdir(parents=True, exist_ok=True) - - backup = _make_backup(p) if p.is_file() else None - - payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n" - fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".tmp_mcp_", suffix=".json") - try: - with os.fdopen(fd, "w", encoding="utf-8") as f: - f.write(payload) - os.replace(tmp, p) # atomic on the same filesystem - finally: - if os.path.exists(tmp): - os.remove(tmp) - return backup - - -# --------------------------------------------------------------------------- # -# Backup utility functions -# --------------------------------------------------------------------------- # -_BACKUP_TS_RE = re.compile(r"(\d{8}-\d{6})") - - -def list_backups(config_path: Path | str) -> list[Path]: - """Return backup files for config_path, newest-first. Returns [] if none exist.""" - p = Path(config_path) - bdir = p.parent / BACKUP_DIRNAME - if not bdir.is_dir(): - return [] - return sorted(bdir.glob(f"{p.stem}.*.json"), reverse=True) - - -def backup_label(backup_path: Path | str) -> str: - """Human-readable label derived from the backup filename timestamp.""" - m = _BACKUP_TS_RE.search(Path(backup_path).name) - if not m: - return Path(backup_path).name - ts = m.group(1) # e.g. "20260702-004124" - return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}" - - -def _redact_server_data(data: dict) -> dict: - """Return a copy of a server definition with secrets masked for display.""" - out = dict(data) - if "args" in out: - out["args"] = redact_args(list(out["args"] or [])) - if "env" in out: - out["env"] = {k: (MASK if is_secret_key(k) else v) for k, v in (out["env"] or {}).items()} - return out - - -def _redact_servers_block(block: dict | None) -> dict: - """Return a sanitized copy of a mcpServers / _disabledMcpServers block.""" - if not block: - return {} - return {name: _redact_server_data(data) for name, data in block.items()} - - -def _server_sections(cfg: dict) -> dict: - """Return the masked server sections of a config dict, safe for diff display.""" - out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))} - if DISABLED_KEY in cfg: - out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY)) - return out - - -def backup_diff( - config_path: Path | str, - backup_path: Path | str, - current_cfg: dict | None = None, -) -> str: - """ - Unified diff showing what the server sections would look like after restoring - the backup. Only mcpServers and _disabledMcpServers are compared; non-server - keys are excluded entirely (they are preserved by restore, not changed). - Secret values in args and env are masked so the diff is safe to share. - - fromfile = current state, tofile = what will be written after restore. - Pass current_cfg to diff against an already-loaded in-memory config instead - of re-reading from disk. - """ - p = Path(config_path) - bp = Path(backup_path) - - if current_cfg is None: - try: - current_cfg = load_config(p) - except Exception: - current_cfg = {} - - backup_servers = extract_servers(load_config(bp)) - after_cfg = dict(current_cfg) - apply_servers(after_cfg, backup_servers) - - before_lines = ( - json.dumps(_server_sections(current_cfg), indent=2, ensure_ascii=False) + "\n" - ).splitlines(keepends=True) - after_lines = ( - json.dumps(_server_sections(after_cfg), indent=2, ensure_ascii=False) + "\n" - ).splitlines(keepends=True) - - result = "".join( - difflib.unified_diff( - before_lines, - after_lines, - fromfile=f"current: {p.name}", - tofile=f"restore: {bp.name}", - ) - ) - return result or "(no differences — backup matches current servers)" - - -def restore_backup( - config_path: Path | str, - backup_path: Path | str, - current_cfg: dict | None = None, -) -> Path | None: - """ - Restore server entries from backup_path into config_path. - - Only mcpServers and _disabledMcpServers are replaced; all other keys in the - current config (conversation history, project state, etc.) are preserved - verbatim and in their original order. - - Goes through write_config() so a pre-restore backup of the current file is - created automatically. Returns that backup path (or None if no prior file). - """ - p = Path(config_path) - if current_cfg is None: - try: - current_cfg = load_config(p) - except Exception: - current_cfg = {} - - backup_servers = extract_servers(load_config(Path(backup_path))) - cfg = dict(current_cfg) - apply_servers(cfg, backup_servers) - return write_config(p, cfg) - - -def config_mtime(path: Path | str) -> float | None: - """Return the file's mtime, or None if the file does not exist.""" - try: - return Path(path).stat().st_mtime - except OSError: - return None - - -class ConfigStat(NamedTuple): - """A snapshot of a config file's mtime + size. - - Pairing size with mtime hardens stale-file detection beyond bare mtime - equality: a concurrent external write can land within the filesystem's - mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime - restored by the writing process, in which case mtime alone would miss the - change. Comparing both fields catches those cases without the cost of a - full content hash. - """ - - mtime: float - size: int - - -def config_fingerprint(path: Path | str) -> ConfigStat | None: - """Return the file's (mtime, size) snapshot, or None if it does not exist.""" - try: - st = Path(path).stat() - except OSError: - return None - return ConfigStat(st.st_mtime, st.st_size) - - -def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: - """ - Compare original_cfg (what BCC loaded) with the current on-disk state. - - Returns: - changed_keys — sorted list of top-level keys whose values differ. - server_diff — masked unified diff of server sections (empty if unchanged - or the file cannot be read). - """ - try: - disk_cfg = load_config(Path(path)) - except Exception: - return [], "" - - all_keys = set(original_cfg) | set(disk_cfg) - changed_keys = sorted(k for k in all_keys if original_cfg.get(k) != disk_cfg.get(k)) - - server_diff = "" - if any(k in {"mcpServers", DISABLED_KEY} for k in changed_keys): - before_lines = ( - json.dumps(_server_sections(original_cfg), indent=2, ensure_ascii=False) + "\n" - ).splitlines(keepends=True) - after_lines = ( - json.dumps(_server_sections(disk_cfg), indent=2, ensure_ascii=False) + "\n" - ).splitlines(keepends=True) - server_diff = "".join( - difflib.unified_diff(before_lines, after_lines, fromfile="loaded", tofile="on disk now") - ) - - return changed_keys, server_diff - - -# --------------------------------------------------------------------------- # -# Paste / import parsing -# --------------------------------------------------------------------------- # -def looks_like_server(data) -> bool: - return isinstance(data, dict) and ("command" in data or "url" in data) - - -def suggest_name(data: dict) -> str: - """Derive a friendly default name from a server definition.""" - if "url" in data and "command" not in data: - host = urlparse(data["url"]).hostname or "remote" - return host.split(".")[0] or "remote" - for a in data.get("args", []) or []: - if isinstance(a, str) and ("/" in a or a.startswith("@")): - base = a.rstrip("/").split("/")[-1] - base = base.replace("server-", "").replace("mcp-server-", "").replace("mcp-", "") - if base: - return base - cmd = data.get("command", "server") - return Path(str(cmd)).stem or "server" - - -# --------------------------------------------------------------------------- # -# Lenient JSON repair -# -# Snippets pasted from MCP docs, blog posts, and chat windows are frequently -# not valid JSON: markdown fences, surrounding prose, // comments, trailing -# commas, smart quotes, single quotes, unquoted keys, missing braces. The -# whole point of BCC is that nobody should have to hand-fix JSON, so the -# paste pipeline repairs what it can and reports what it changed. -# --------------------------------------------------------------------------- # - -# Word-processor / web artifacts mapped back to ASCII. -_QUOTE_MAP = { - "“": '"', - "”": '"', - "„": '"', - "«": '"', - "»": '"', - "‘": "'", - "’": "'", - "‚": "'", -} -_JUNK_CHARS = "​‌‍" # zero-width chars / BOM - - -def _strip_fences(text: str, notes: list[str]) -> str: - """Pull the contents out of a ```json ... ``` fence (or drop stray fence lines).""" - m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE) - if m: - notes.append("extracted code from markdown fence") - return m.group(1) - if "```" in text: - notes.append("removed markdown fence markers") - return text.replace("```json", "").replace("```", "") - return text - - -def _normalize_unicode(text: str, notes: list[str]) -> str: - out = text - for junk in _JUNK_CHARS: - out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space - for smart, ascii_q in _QUOTE_MAP.items(): - out = out.replace(smart, ascii_q) - if out != text: - notes.append("normalized smart quotes / invisible characters") - return out - - -def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]: - """ - One careful pass over the text producing (is_string, segment) pairs. - While walking it also: converts single-quoted strings to double-quoted, - and drops //, /* */ and # comments (never inside strings). - """ - segs: list[tuple[bool, str]] = [] - buf: list[str] = [] - i, n = 0, len(text) - - def flush(): - if buf: - segs.append((False, "".join(buf))) - buf.clear() - - while i < n: - ch = text[i] - if ch == '"': # proper double-quoted string: copy verbatim - flush() - j = i + 1 - s = ['"'] - while j < n: - c = text[j] - s.append(c) - if c == "\\" and j + 1 < n: - s.append(text[j + 1]) - j += 2 - continue - if c == '"': - j += 1 - break - j += 1 - segs.append((True, "".join(s))) - i = j - elif ch == "'": # single-quoted string: convert to double-quoted - flush() - j = i + 1 - inner: list[str] = [] - while j < n: - c = text[j] - if c == "\\" and j + 1 < n: - nxt = text[j + 1] - if nxt == "'": - inner.append("'") # \' has no meaning in JSON - else: - inner.append(c) - inner.append(nxt) - j += 2 - continue - if c == "'": - j += 1 - break - if c == '"': - inner.append('\\"') - j += 1 - continue - inner.append(c) - j += 1 - segs.append((True, '"' + "".join(inner) + '"')) - notes.append("converted single-quoted strings") - i = j - elif ch == "/" and i + 1 < n and text[i + 1] == "/": - notes.append("removed // comments") - while i < n and text[i] != "\n": - i += 1 - elif ch == "/" and i + 1 < n and text[i + 1] == "*": - notes.append("removed /* */ comments") - i += 2 - while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): - i += 1 - i = min(i + 2, n) - elif ch == "#": - # Only treat as a comment at line start (after whitespace) — never - # mid-value, where # could be part of an unquoted token. - line_start = text.rfind("\n", 0, i) + 1 - if text[line_start:i].strip() == "": - notes.append("removed # comments") - while i < n and text[i] != "\n": - i += 1 - else: - buf.append(ch) - i += 1 - else: - buf.append(ch) - i += 1 - flush() - return segs - - -def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str: - """Structural fixes that only apply outside strings.""" - fixed: list[str] = [] - for idx, (is_str, seg) in enumerate(segs): - if is_str: - fixed.append(seg) - continue - s = seg - # Python / JS literals -> JSON - s2 = re.sub(r"\bTrue\b", "true", s) - s2 = re.sub(r"\bFalse\b", "false", s2) - s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2) - if s2 != s: - notes.append("converted Python/JS literals (True/False/None)") - s = s2 - # Unquoted keys: { key: or , key: -> "key": - s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s) - if s2 != s: - notes.append("quoted bare object keys") - s = s2 - # Missing comma before the next string on a new line, e.g. - # "args": ["x"]\n"env": {...} or {...}\n"name2": {...} - # A string directly after a value-ish ending across a newline can never - # be valid JSON without a comma, so inserting one is always safe. - if idx + 1 < len(segs) and segs[idx + 1][0]: - body = s.rstrip() - tail_ws = s[len(body) :] - prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "") - value_ending = prev in ('"', "}", "]") or prev.isdigit() - if "\n" in tail_ws and value_ending: - notes.append("inserted missing commas") - s = body + "," + tail_ws - fixed.append(s) - - out = "".join(fixed) - # Trailing commas (safe now: strings are intact, commas here are structural) - segs2 = _tokenize(out, []) - parts: list[str] = [] - changed = False - for is_str, seg in segs2: - if is_str: - parts.append(seg) - else: - new = re.sub(r",(\s*[}\]])", r"\1", seg) - changed = changed or new != seg - parts.append(new) - if changed: - notes.append("removed trailing commas") - return "".join(parts) - - -def _extract_and_balance(text: str, notes: list[str]) -> str: - """Slice out the JSON object (dropping surrounding prose) and close any - unclosed braces/brackets.""" - start = text.find("{") - if start == -1: - return text - if text[:start].strip(): - notes.append("ignored text before the JSON block") - - stack: list[str] = [] - i, n = start, len(text) - end = -1 - while i < n: - ch = text[i] - if ch == '"': # skip strings - i += 1 - while i < n: - if text[i] == "\\": - i += 2 - continue - if text[i] == '"': - break - i += 1 - elif ch in "{[": - stack.append("}" if ch == "{" else "]") - elif ch in "}]": - if stack and stack[-1] == ch: - stack.pop() - if not stack: - end = i + 1 - break - i += 1 - - if end != -1: - if text[end:].strip(): - notes.append("ignored text after the JSON block") - return text[start:end] - # Ran out of input with open scopes: close them. - if stack: - notes.append("closed unclosed braces/brackets") - return text[start:] + "".join(reversed(stack)) - return text[start:] - - -def repair_json_text(text: str) -> tuple[str, list[str]]: - """ - Best-effort repair of an almost-JSON snippet. Returns (candidate, notes) - where notes is a human-readable list of the fixes applied (deduplicated, - in order). Does NOT guarantee the result parses — callers still try - json.loads and surface its error if repair wasn't enough. - """ - notes: list[str] = [] - t = _strip_fences(text, notes) - t = _normalize_unicode(t, notes) - t = t.strip() - - # Brace-less inner fragment: "name": { ... } (with no outer braces). - # A quoted key at the start is a strong signal; a bare word only counts - # when followed by '{' so prose like "Note: ..." isn't swallowed. - if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t): - notes.append("wrapped fragment in braces") - t = "{" + t + "}" - - t = _extract_and_balance(t, notes) - segs = _tokenize(t, notes) - t = _repair_segments(segs, notes) - - # Re-balance in case comment/quote fixes exposed structure. - t = _extract_and_balance(t, []) - - seen: set[str] = set() - unique = [x for x in notes if not (x in seen or seen.add(x))] - return t, unique - - -def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]: - """ - Like parse_pasted_json, but forgiving. Tries strict JSON first; if that - fails, runs repair_json_text() and retries. Returns (servers, repair_notes). - repair_notes is empty when the input was already valid. - """ - raw = (text or "").strip() - if not raw: - raise ValueError("Nothing to parse.") - try: - return _shape_servers(json.loads(raw)), [] - except json.JSONDecodeError as strict_err: - candidate, notes = repair_json_text(raw) - try: - obj = json.loads(candidate) - except json.JSONDecodeError: - # Repair wasn't enough — report the original, more meaningful error. - raise ValueError( - f"Couldn't parse that as JSON even after auto-repair " - f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})." - ) from strict_err - return _shape_servers(obj), notes - - -def _shape_servers(obj) -> dict[str, dict]: - """Shared shape-detection for parsed paste content.""" - if not isinstance(obj, dict): - raise ValueError("Top-level JSON must be an object ({ ... }).") - - if isinstance(obj.get("mcpServers"), dict): - servers = obj["mcpServers"] - elif looks_like_server(obj): - servers = {suggest_name(obj): obj} - elif obj and all(isinstance(v, dict) for v in obj.values()): - servers = obj - else: - raise ValueError("Couldn't find any MCP server definitions in that JSON.") - - clean: dict[str, dict] = {} - for name, data in servers.items(): - if not looks_like_server(data): - raise ValueError( - f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')." - ) - clean[str(name)] = data - return clean - - -def parse_pasted_json(text: str) -> dict[str, dict]: - """ - Accept any of the shapes MCP docs hand out and return {name: server_dict}: - - 1. Full config: {"mcpServers": {"name": {...}}} - 2. Inner block only: {"name": {"command": ...}} - 3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) - - Input doesn't have to be valid JSON — markdown fences, comments, trailing - commas, smart/single quotes, unquoted keys, surrounding prose, and missing - braces are repaired automatically (see repair_json_text). - - Raises ValueError with a human message on anything unrecognizable. - """ - servers, _notes = parse_pasted_json_verbose(text) - return servers - - -# --------------------------------------------------------------------------- # -# Secrets: detection + redaction -# --------------------------------------------------------------------------- # -_SECRET_KEY_RE = re.compile( - r"(?i)(token|secret|passw|api[-_]?key|apikey|auth|credential|bearer|private[-_]?key|access[-_]?key)" -) - -# Well-known token prefixes that identify a bare value as a secret even -# without a telling key/flag name next to it. -_TOKEN_PREFIXES = ( - "ghp_", - "gho_", - "ghu_", - "ghs_", - "github_pat_", # GitHub - "sk-", - "sk_live_", - "sk_test_", - "rk_live_", # OpenAI / Stripe - "xoxb-", - "xoxp-", - "xoxc-", - "xoxs-", - "xapp-", # Slack - "glpat-", - "gldt-", # GitLab - "AKIA", - "ASIA", # AWS access key IDs - "ya29.", - "AIza", # Google - "pypi-", - "npm_", - "dop_v1_", - "figd_", -) - -MASK = "••••••••" - -# Matches userinfo credentials embedded in a URL: scheme://user:pass@host -# Fires on postgres://user:pass@host but NOT on https://host/path or ssh://user@host. -_EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@") - - -def is_secret_key(name: str) -> bool: - """Does this env-var / header / flag name look like it holds a secret?""" - return bool(_SECRET_KEY_RE.search(name or "")) - - -def _is_secret_value(value: str) -> bool: - return isinstance(value, str) and value.startswith(_TOKEN_PREFIXES) - - -def redact_args(args: list[str]) -> list[str]: - """ - Mask secret values in an args list for display/diagnostics: - --token abc123 -> --token •••••••• (value after a secret flag) - --api-key=abc123 -> --api-key=•••••••• (inline flag=value) - ghp_abc123 -> •••••••• (well-known token prefix) - Everything else passes through untouched. - """ - out: list[str] = [] - mask_next = False - for a in args: - s = str(a) - if mask_next: - out.append(MASK) - mask_next = False - continue - if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]): - out.append(s.split("=", 1)[0] + "=" + MASK) - continue - if s.startswith("-") and is_secret_key(s): - out.append(s) - mask_next = True - continue - if _is_secret_value(s): - out.append(MASK) - continue - out.append(s) - return out - - -def args_secret_warning(data: dict) -> str | None: - """ - Return a warning string when any arg looks like a raw secret that would - be better placed in `env`. Returns None when no concern is found. - - Skips --flag=value inline pairs (already partially self-documenting). - Fires on: - - positional values that start with a well-known token prefix (ghp_, sk-, …) - - values that follow a secret-named flag (--token abc, --api-key abc) - - URLs with embedded user:pass credentials (postgres://user:pass@host) - """ - args = [str(a) for a in (data.get("args") or [])] - mask_next = False - for a in args: - if mask_next: - mask_next = False - if not a.startswith("-"): - return ( - "An arg value following a secret-named flag looks like a credential. " - "Where the server supports it, prefer Environment variables — " - "args are visible in process listings." - ) - continue - # --flag=value inline: skip (the flag name already labels it) - if a.startswith("-") and "=" in a: - continue - # --secretflag (no inline value): flag the next positional arg - if a.startswith("-") and is_secret_key(a): - mask_next = True - continue - if a.startswith("-"): - continue - # Positional value: check for token prefix or embedded URL credentials - if _is_secret_value(a) or _EMBEDDED_CRED_RE.search(a): - return ( - "An arg value looks like a credential. " - "Where the server supports it, prefer Environment variables — " - "args are visible in process listings." - ) - return None - - -def split_suspicious_args(args: list[str]) -> tuple[list[str], list[str]]: - """ - Detect the classic argument-entry mistake: several argv tokens typed on one - line ("--directory /path/to/server"). An entry is only flagged when it - contains whitespace AND at least one whitespace-separated token starts with - "-" — so legitimate single arguments with spaces ("My Project Notes", - "/Users/me/My Documents") are never touched. - - Returns (fixed_args, notes). notes is empty when nothing was suspicious; - otherwise it describes each split so a UI can show the proposed fix. - Quotes are respected when splitting ('--name "My Server"' becomes - ['--name', 'My Server']). - """ - import shlex - - out: list[str] = [] - notes: list[str] = [] - for a in args: - if isinstance(a, str) and _looks_like_multiple_args(a): - try: - parts = shlex.split(a) - except ValueError: # unbalanced quotes — fall back to plain split - parts = a.split() - if len(parts) > 1: - out.extend(parts) - notes.append(f"“{a}” looks like {len(parts)} arguments — split onto separate lines") - continue - out.append(a) - return out, notes - - -def _looks_like_multiple_args(a: str) -> bool: - toks = a.split() - return len(toks) > 1 and any(t.startswith("-") for t in toks) - - -# --------------------------------------------------------------------------- # -# Validation -# --------------------------------------------------------------------------- # -def validate_servers(servers: list[ServerEntry]) -> list[str]: - """Return a list of human-readable problems. Empty list == all good.""" - problems: list[str] = [] - seen: dict[str, int] = {} - for s in servers: - nm = s.name.strip() - if not nm: - problems.append("A server has an empty name.") - seen[nm] = seen.get(nm, 0) + 1 - if s.kind == "stdio": - if not str(s.data.get("command", "")).strip(): - problems.append(f"'{nm or '(unnamed)'}' is a local server but has no command.") - else: - url = str(s.data.get("url", "")).strip() - if not url: - problems.append(f"'{nm or '(unnamed)'}' is a remote server but has no URL.") - elif not (url.startswith("http://") or url.startswith("https://")): - problems.append(f"'{nm}' has a URL that isn't http(s).") - for nm, count in seen.items(): - if nm and count > 1: - problems.append(f"Duplicate server name: '{nm}' ({count}x).") - return problems - - -# --------------------------------------------------------------------------- # -# Dependency / PATH checking -# --------------------------------------------------------------------------- # -@functools.lru_cache(maxsize=1) -def system_path() -> str: - """ - The PATH that a GUI app (e.g., Claude Desktop launched from Finder/dock) - reliably inherits, independent of how BCC itself was started. - - This is used to determine the 'ok' vs 'warn' distinction: - ok = found in system_path() → works however Claude is launched - warn = found only in augmented_path() → works from a terminal but may - not work when Claude is opened from the desktop - - macOS : /etc/paths + /etc/paths.d/* (what launchd provides) plus - well-known package-manager prefixes (/opt/homebrew, /opt/local). - Windows: system + user PATH from the registry. - Linux : /etc/environment + standard FHS dirs + /snap/bin. - """ - dirs: list[str] = [] - - if sys.platform == "darwin": - for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]: - with contextlib.suppress(OSError): - dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip()) - # Package-manager install prefixes: present on disk once installed, - # accessible to all processes regardless of shell configuration. - for d in ( - "/opt/homebrew/bin", - "/opt/homebrew/sbin", # Homebrew (Apple Silicon) - "/usr/local/bin", - "/usr/local/sbin", # Homebrew (Intel) / manual installs - "/opt/local/bin", - "/opt/local/sbin", # MacPorts - ): - dirs.append(d) - - elif os.name == "nt": - try: - import winreg - - for hive, sub in [ - ( - winreg.HKEY_LOCAL_MACHINE, - r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", - ), - (winreg.HKEY_CURRENT_USER, r"Environment"), - ]: - try: - with winreg.OpenKey(hive, sub) as k: - val, _ = winreg.QueryValueEx(k, "PATH") - dirs.extend(os.path.expandvars(val).split(os.pathsep)) - except OSError: - pass - except ImportError: - pass - dirs.extend( - [ - "C:\\Windows\\System32", - "C:\\Windows", - "C:\\Windows\\System32\\Wbem", - os.path.expandvars(r"%ProgramFiles%\nodejs"), - ] - ) - - else: # Linux / other - try: - for line in Path("/etc/environment").read_text().splitlines(): - if line.upper().startswith("PATH="): - dirs.extend(line[5:].strip("\"'").split(os.pathsep)) - except OSError: - pass - for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"): - dirs.append(d) - - seen, out = set(), [] - for d in dirs: - d = d.strip() - if d and d not in seen: - seen.add(d) - out.append(d) - return os.pathsep.join(out) - - -@functools.lru_cache(maxsize=1) -def augmented_path() -> str: - """ - The widest PATH BCC searches when looking for a command — system_path() - plus user-specific runtime locations (nvm, cargo, volta, bun, etc.) that - require shell configuration to be active. - - Memoized for the session. Call refresh_path_cache() to re-scan, e.g. - after the user installs a new runtime. - """ - base = system_path().split(os.pathsep) - # Also include the current process PATH (catches unusual CI / container setups) - env_parts = os.environ.get("PATH", "").split(os.pathsep) - home = Path.home() - user_dirs = [ - str(home / ".local" / "bin"), - str(home / ".cargo" / "bin"), - str(home / ".deno" / "bin"), - str(home / ".bun" / "bin"), - str(home / ".volta" / "bin"), - ] - user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) - user_dirs += glob.glob( - str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin") - ) - if os.name == "nt": - appdata = os.environ.get("APPDATA", "") - if appdata: - user_dirs.append(str(Path(appdata) / "npm")) - - seen, ordered = set(), [] - for p in base + env_parts + user_dirs: - if p and p not in seen and Path(p).is_dir(): - seen.add(p) - ordered.append(p) - return os.pathsep.join(ordered) - - -def refresh_path_cache(): - """Forget memoized PATHs so the next check re-scans (e.g. after an install).""" - system_path.cache_clear() - augmented_path.cache_clear() - - -# Tailored install advice keyed by the runner's basename. -RUNNER_HINTS = { - "npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. " - "With nvm, the binary lives at ~/.nvm/versions/node//bin.", - "node": "Node.js not found. Install from nodejs.org or `brew install node`.", - "uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " - "(or `brew install uv`). uvx ships inside uv.", - "uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " - "(or `brew install uv`).", - "python": "Python not found on this PATH.", - "python3": "Python 3 not found on this PATH.", - "docker": "Docker not found. Install Docker Desktop and make sure it's running.", - "bun": "Bun not found. Install with `curl -fsSL https://bun.sh/install | bash`.", - "deno": "Deno not found. Install with `curl -fsSL https://deno.land/install.sh | sh`.", -} - - -def runner_hint(cmd: str) -> str: - base = Path(cmd).name.lower() - if base.endswith(".exe"): - base = base[:-4] - if base in RUNNER_HINTS: - return RUNNER_HINTS[base] - if ("/" in cmd) or ("\\" in cmd): - return ( - f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " - f"Check the path and that it's marked executable (chmod +x)." - ) - return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'." - - -SHELL_WRAPPERS = {"cmd", "cmd.exe", "sh", "bash", "zsh", "powershell", "powershell.exe", "pwsh"} - - -def _commands_to_check(data: dict) -> list[str]: - cmd = data.get("command") - if not cmd: - return [] - cmds = [str(cmd)] - base = Path(str(cmd)).name.lower() - if base in SHELL_WRAPPERS: - for a in data.get("args", []) or []: - if isinstance(a, str) and not a.startswith(("/", "-")) and " " not in a: - cmds.append(a) - break - return cmds - - -def check_dependency(data: dict, path: str | None = None) -> dict: - """ - Decide whether a server's command can actually be found, and gather enough - context to troubleshoot when it can't. - - status is one of: - 'ok' - resolved on the normal (inherited) PATH; will work anywhere. - 'warn' - resolved, but ONLY via an augmented location. Works in a - terminal launch; a bundled .app launch of Claude may not see it. - 'missing' - not found anywhere. - 'remote' - a url-based server (no local command to check). - 'unknown' - no command set. - - Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail} - """ - if "url" in data and "command" not in data: - return { - "status": "remote", - "label": "remote endpoint", - "detail": data.get("url", ""), - "resolved": {}, - "in_base": {}, - "searched_path": "", - "hints": [], - "commands": [], - } - - cmds = _commands_to_check(data) - if not cmds: - return { - "status": "unknown", - "label": "no command set", - "detail": "", - "resolved": {}, - "in_base": {}, - "searched_path": "", - "hints": ["This server has no 'command' to run."], - "commands": [], - } - - aug = path or augmented_path() - resolved: dict[str, str | None] = {} - in_base: dict[str, bool] = {} - for c in cmds: - if (os.sep in c) or ("/" in c): # explicit path, independent of PATH - cp = Path(c) - ok = cp.exists() and os.access(cp, os.X_OK) - resolved[c] = str(cp) if ok else None - in_base[c] = ok - else: - resolved[c] = shutil.which(c, path=aug) - # 'in_base' means: found in system_path() — the PATH that Claude - # Desktop (or any GUI app) reliably inherits regardless of shell config. - # This check is consistent whether BCC runs from a terminal or as a .app. - in_base[c] = bool(shutil.which(c, path=system_path())) - - missing = [c for c, r in resolved.items() if not r] - hints: list[str] = [] - - if missing: - for c in missing: - hints.append(runner_hint(c)) - status = "missing" - label = "missing: " + ", ".join(missing) - else: - nonbase = [c for c in resolved if not in_base[c]] - if nonbase: - status = "warn" - label = "found, but only on a non-standard PATH" - for c in nonbase: - hints.append( - f"'{c}' was found at {resolved[c]}, but that location is not in the standard " - f"system PATH. Claude Desktop launched from Finder or the dock may not see it. " - f"Clicking 'Use full path ↳' rewrites the command to its absolute path, which " - f"works regardless of how Claude is launched." - ) - else: - status = "ok" - label = "found: " + ", ".join(Path(p).name for p in resolved.values()) - - return { - "status": status, - "label": label, - "resolved": resolved, - "in_base": in_base, - "searched_path": aug, - "hints": hints, - "commands": cmds, - "detail": "", - } - - -def diagnostics_text(name: str, data: dict) -> str: - """A copy-pasteable plain-text troubleshooting report for one server.""" - res = check_dependency(data) - L: list[str] = [] - if res["status"] == "remote": - L.append(f"Server: {name or '(unnamed)'} [remote]") - L.append(f"URL: {data.get('url', '')}") - if data.get("type"): - L.append(f"Transport: {data['type']}") - if data.get("headers"): - L.append("Headers: " + ", ".join(data["headers"].keys())) - L.append("Status: REMOTE (network reachability is not checked)") - return "\n".join(L) - - L.append(f"Server: {name or '(unnamed)'} [local / stdio]") - L.append(f"Command: {data.get('command', '')}") - if data.get("args"): - # Redacted: this report is designed to be pasted into bug reports. - L.append("Args: " + " ".join(redact_args(list(data["args"])))) - if data.get("env"): - L.append("Env keys: " + ", ".join(data["env"].keys())) - L.append(f"Status: {res['status'].upper()}") - L.append("") - L.append("Command resolution:") - for c, r in res["resolved"].items(): - if r: - tag = "" if res["in_base"].get(c) else " <-- non-standard PATH; app may not see it" - L.append(f" {c} -> {r}{tag}") - else: - L.append(f" {c} -> NOT FOUND") - if res["hints"]: - L.append("") - L.append("Hints:") - for h in res["hints"]: - L.append(f" - {h}") - - sys_dirs = set(system_path().split(os.pathsep)) - aug_dirs = res["searched_path"].split(os.pathsep) if res["searched_path"] else [] - extra = [d for d in aug_dirs if d not in sys_dirs] - L.append("") - L.append(f"PATH searched ({len(aug_dirs)} dirs):") - for d in aug_dirs: - L.append((" + " if d in extra else " ") + d) - if extra: - L.append("") - L.append( - "(+ = user-specific dirs not in the standard system PATH. " - "Claude Desktop launched from Finder may NOT have these.)" - ) - return "\n".join(L) - - -_STDERR_CAP = 4096 # bytes - - -def spawn_test(data: dict, timeout: float = 3.0) -> dict: - """ - Attempt to start a stdio server and observe it for `timeout` seconds. - - Returns a dict: - outcome: "ok" — still running after timeout; server started correctly - "exited" — exited with code 0 before timeout; unusual for a server - "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 - returncode: int | None - stderr: str (first ~4 KB) - detail: str - - Run this off the UI thread — it blocks for up to `timeout` seconds. - """ - if "url" in data and "command" not in data: - return { - "outcome": "not_applicable", - "returncode": None, - "stderr": "", - "detail": "remote server", - } - - cmd = (data.get("command") or "").strip() - if not cmd: - return { - "outcome": "not_applicable", - "returncode": None, - "stderr": "", - "detail": "no command set", - } - - # Resolve to absolute path so subprocess doesn't fight with PATH in env. - resolved_cmd = shutil.which(cmd, path=augmented_path()) - if resolved_cmd is None: - return { - "outcome": "not_found", - "returncode": None, - "stderr": "", - "detail": f"command not found: {cmd}", - } - - 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 {}) - - stderr_chunks: list[bytes] = [] - - def _drain(pipe) -> None: - # Read until EOF so the pipe buffer never fills and blocks the subprocess. - # Only keep the first _STDERR_CAP bytes; the rest is discarded. - captured = 0 - try: - while True: - chunk = pipe.read(1024) - if not chunk: - break - if captured < _STDERR_CAP: - keep = min(len(chunk), _STDERR_CAP - captured) - stderr_chunks.append(chunk[:keep]) - captured += keep - except Exception: - pass - - popen_kwargs: dict = dict( - stdin=subprocess.PIPE, # keep open so servers block on read rather than seeing EOF - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - env=merged_env, - ) - if os.name != "nt": - popen_kwargs["start_new_session"] = True # own process group → clean kill - - try: - proc = subprocess.Popen(args_list, **popen_kwargs) - except (FileNotFoundError, OSError) as e: - return { - "outcome": "not_found", - "returncode": None, - "stderr": "", - "detail": str(e), - } - - drain_thread = threading.Thread(target=_drain, args=(proc.stderr,), daemon=True) - drain_thread.start() - - try: - proc.wait(timeout=timeout) - except subprocess.TimeoutExpired: - try: - if os.name != "nt": - os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) - else: - proc.kill() # best-effort on Windows - except OSError: - pass - with contextlib.suppress(subprocess.TimeoutExpired): - proc.wait(timeout=1.0) - drain_thread.join(0.5) - stderr = b"".join(stderr_chunks).decode(errors="replace") - return { - "outcome": "ok", - "returncode": None, - "stderr": stderr, - "detail": f"still running after {timeout:.0f}s — server started successfully", - } - - drain_thread.join(0.5) - stderr = b"".join(stderr_chunks).decode(errors="replace") - rc = proc.returncode - if rc == 0: - return { - "outcome": "exited", - "returncode": rc, - "stderr": stderr, - "detail": "process exited cleanly (code 0) — unusual; a healthy server should keep running", - } - return { - "outcome": "crashed", - "returncode": rc, - "stderr": stderr, - "detail": f"process exited with code {rc}", - } - - -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) - counts as reachable; only connection/DNS/timeout failures count as unreachable. - Returns (ok, detail). Network call — run this off the UI thread. - """ - import socket - import urllib.error - import urllib.request - - url = (url or "").strip() - if not (url.startswith("http://") or url.startswith("https://")): - return False, "not an http(s) URL" - req = urllib.request.Request( - url, - method="GET", - headers={ - "Accept": "text/event-stream, application/json, */*", - "User-Agent": "BetterClaudeConfig/1.0", - }, - ) - try: - with urllib.request.urlopen(req, timeout=timeout) as r: - return True, f"HTTP {r.status}" - except urllib.error.HTTPError as e: - return True, f"HTTP {e.code} (reachable)" - except urllib.error.URLError as e: - reason = getattr(e, "reason", e) - if isinstance(reason, socket.timeout): - return False, "timed out" - return False, str(reason) - except TimeoutError: - return False, "timed out" - except Exception as e: - return False, str(e) - - -def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | None]: - """ - Fix a 'PATH-risk' (warn) server by rewriting the bare runner name to the - absolute path it resolved to, so any process (incl. a bundled Claude.app) - can find it. Returns (new_data, note). note is None if nothing was pinned. - """ - res = check_dependency(data, path) - if res["status"] != "warn": - return data, None - out = dict(data) - for c, resolved in res["resolved"].items(): - if resolved and not res["in_base"].get(c, True) and ("/" not in c and "\\" not in c): - if str(out.get("command", "")) == c: - out["command"] = resolved - return out, f"command → {resolved}" - args = list(out.get("args", []) or []) - for i, a in enumerate(args): - if a == c: - args[i] = resolved - out["args"] = args - return out, f"'{c}' → {resolved}" - return data, None +PLACEHOLDER_WILL_REPLACE \ No newline at end of file From 0843c51c7da4eb5c147cf14e2ead7b6aace9eaa9 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:27:24 -0400 Subject: [PATCH 11/30] tests/test_core.py: add resolve_name_collision unit tests --- tests/test_core.py | 129 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 121 insertions(+), 8 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..9960fa7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,6 +2,7 @@ proper test functions with tmp_path/monkeypatch fixtures).""" import json +import os import sys import pytest @@ -256,20 +257,17 @@ def test_spawn_test_ok_still_running(): def test_spawn_test_crashed(): - # A process that exits with a non-zero code immediately. - # Timeout is generous (not 0.3s) so a slow CI runner's interpreter startup - # can't outlast it and misclassify the crash as "ok" (still running). See #12. + # A process that exits with a non-zero code immediately r = c.spawn_test( - {"command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}, timeout=2.0 + {"command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}, timeout=0.3 ) assert r["outcome"] == "crashed" assert r["returncode"] == 1 def test_spawn_test_exited_cleanly(): - # A process that exits 0 before timeout (unusual for a server). - # Generous timeout so slow-runner startup can't flip this to "ok". See #12. - r = c.spawn_test({"command": sys.executable, "args": ["-c", "pass"]}, timeout=2.0) + # A process that exits 0 before timeout (unusual for a server) + r = c.spawn_test({"command": sys.executable, "args": ["-c", "pass"]}, timeout=0.5) assert r["outcome"] == "exited" assert r["returncode"] == 0 @@ -291,7 +289,7 @@ def test_spawn_test_stderr_captured(): "command": sys.executable, "args": ["-c", "import sys; sys.stderr.write('test-err\\n'); sys.exit(2)"], }, - timeout=2.0, + timeout=0.5, ) assert r["outcome"] == "crashed" assert "test-err" in r["stderr"] @@ -520,6 +518,42 @@ def test_config_mtime_returns_none_for_missing_file(tmp_path): assert c.config_mtime(tmp_path / "nonexistent.json") is None +def test_config_fingerprint_returns_mtime_and_size_for_existing_file(tmp_path): + f = tmp_path / "cfg.json" + f.write_text("{}") + fp = c.config_fingerprint(f) + assert isinstance(fp, c.ConfigStat) + assert isinstance(fp.mtime, float) + assert fp.size == f.stat().st_size + + +def test_config_fingerprint_returns_none_for_missing_file(tmp_path): + assert c.config_fingerprint(tmp_path / "nonexistent.json") is None + + +def test_config_fingerprint_detects_size_change_when_mtime_is_unchanged(tmp_path): + """ + Guards against the exact hole bare-mtime comparison has: an external write + that lands within the filesystem's mtime resolution (or that restores the + original mtime) must still be caught, because the file's size differs. + """ + f = tmp_path / "cfg.json" + f.write_text('{"mcpServers": {}}') + original = c.config_fingerprint(f) + original_mtime_ns = f.stat().st_mtime_ns + + # Overwrite with materially different (larger) content, then force the + # mtime back to its original value — simulating a same-second external + # write, or a writer that preserves mtime. + f.write_text('{"mcpServers": {"new-server": {"command": "node", "args": ["a", "b", "c"]}}}') + os.utime(f, ns=(original_mtime_ns, original_mtime_ns)) + + updated = c.config_fingerprint(f) + assert updated.mtime == original.mtime # mtime alone would say "unchanged" + assert updated.size != original.size # size catches what mtime missed + assert updated != original # the fingerprint as a whole detects the change + + def test_external_change_summary_detects_non_server_key_change(tmp_path): cfgpath = tmp_path / "cfg.json" original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}} @@ -638,3 +672,82 @@ def test_args_secret_warning_env_not_triggered(): def test_args_secret_warning_empty(): assert c.args_secret_warning({}) is None assert c.args_secret_warning({"args": []}) is None + + +# --------------------------------------------------------------------------- # +# resolve_name_collision (paste/import duplicate-name handling — issue #8) +# --------------------------------------------------------------------------- # +def test_resolve_name_collision_no_conflict_returns_unchanged(): + assert c.resolve_name_collision("brave-search", {"other"}) == "brave-search" + + +def test_resolve_name_collision_single_conflict_appends_dash_two(): + assert c.resolve_name_collision("brave-search", {"brave-search"}) == "brave-search-2" + + +def test_resolve_name_collision_skips_taken_suffixes(): + existing = {"brave-search", "brave-search-2", "brave-search-3"} + assert c.resolve_name_collision("brave-search", existing) == "brave-search-4" + + +def test_resolve_name_collision_empty_existing_set(): + assert c.resolve_name_collision("brave-search", set()) == "brave-search" + + +def test_resolve_name_collision_repeated_calls_stay_unique(): + """Simulates dropping the same file twice in a row: each resolved name + must be fed back into `existing` before resolving the next one, or two + servers could end up sharing a name (and silently collapse into one + when apply_servers() rebuilds its dict at save time).""" + existing = {"brave-search"} + first = c.resolve_name_collision("brave-search", existing) + existing.add(first) + second = c.resolve_name_collision("brave-search", existing) + assert first != second + assert {first, second} == {"brave-search-2", "brave-search-3"} + + +# --------------------------------------------------------------------------- # +# server_log_path (in-app log viewer — issue #6) +# --------------------------------------------------------------------------- # +def test_server_log_path_macos_when_file_exists(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + log_dir = tmp_path / "Library" / "Logs" / "Claude" + log_dir.mkdir(parents=True) + (log_dir / "mcp-server-brave-search.log").write_text("hello") + + result = c.server_log_path("brave-search") + assert result == log_dir / "mcp-server-brave-search.log" + + +def test_server_log_path_macos_none_when_missing(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + assert c.server_log_path("brave-search") is None + + +def test_server_log_path_windows_when_file_exists(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(c.os, "name", "nt") + monkeypatch.setenv("APPDATA", str(tmp_path)) + log_dir = tmp_path / "Claude" / "logs" + log_dir.mkdir(parents=True) + (log_dir / "mcp.log").write_text("hello") + + result = c.server_log_path("brave-search") + assert result == log_dir / "mcp.log" + + +def test_server_log_path_windows_none_when_missing(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setattr(c.os, "name", "nt") + monkeypatch.setenv("APPDATA", str(tmp_path)) + assert c.server_log_path("brave-search") is None + + +def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(c.os, "name", "posix") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + assert c.server_log_path("brave-search") is None From cffdee8a40d0d5449c2dfdaeacda1df8f8c7d553 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:29:33 -0400 Subject: [PATCH 12/30] chore: restore trailing newline in bcc_core.py From 5d59c1c42389142c54b5df1f730e826edd94a224 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:30:03 -0400 Subject: [PATCH 13/30] fix: restore actual file content (previous commit had placeholder text by mistake) --- bcc_core.py | 1487 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 1486 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index 987bf95..e5b92ff 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1 +1,1486 @@ -PLACEHOLDER_WILL_REPLACE \ No newline at end of file +""" +mcp_core.py -- Pure logic for the Claude MCP config manager. + +No GUI imports live in here on purpose: every function below is unit-testable +and could just as easily back a CLI. The GUI (mcp_manager.py) is a thin shell +over these functions. + +The cardinal rule of this module: when writing a config back to disk we ONLY +ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking +key). Every other top-level key in the user's config is preserved verbatim and +in its original position. +""" + +from __future__ import annotations + +import contextlib +import difflib +import functools +import glob +import json +import os +import re +import shutil +import signal as _signal +import subprocess +import sys +import tempfile +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import NamedTuple +from urllib.parse import urlparse + +CONFIG_FILENAME = "claude_desktop_config.json" + +# Disabled servers are parked under this non-standard key. Claude Desktop only +# reads `mcpServers`, so anything here is ignored by the app but kept on disk so +# we can toggle it back on without losing the definition. +DISABLED_KEY = "_disabledMcpServers" + +BACKUP_DIRNAME = ".bcc_backups" +MAX_BACKUPS = 15 + +# Fields the editor knows how to render. Anything else on a server object is +# considered "extra" and is preserved untouched on save. +KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} + + +# --------------------------------------------------------------------------- # +# Data model +# --------------------------------------------------------------------------- # +@dataclass +class Profile: + """A discovered (or manually added) Claude install location.""" + + label: str + path: Path + config_exists: bool + + def __post_init__(self): + self.path = Path(self.path) + + +@dataclass +class ServerEntry: + name: str + data: dict + enabled: bool = True + + @property + def kind(self) -> str: + return "remote" if "url" in self.data and "command" not in self.data else "stdio" + + +# --------------------------------------------------------------------------- # +# Discovery +# --------------------------------------------------------------------------- # +def app_support_base() -> Path: + """The per-platform directory that holds the `Claude*` data folders.""" + if sys.platform == "darwin": + return Path.home() / "Library" / "Application Support" + if os.name == "nt": + return Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) + return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + + +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: scans the platform app-support folder for any `Claude*` + directory (catches `Claude`, `Claude-Work`, etc.). + 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. + """ + base = app_support_base() + out: list[Profile] = [] + if base.is_dir(): + seen = set() + for d in sorted(base.glob("Claude*")): + if d.is_dir() and d.name not in seen: + seen.add(d.name) + cfg = d / CONFIG_FILENAME + out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file())) + + home = Path.home() + cc_cfg = home / ".claude.json" + out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) + + # 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 + # Copy to ▸ the entries into the real config. + legacy = home / ".claude" / "settings.json" + if legacy.is_file(): + with contextlib.suppress(Exception): + if "mcpServers" in load_config(legacy): + out.append( + Profile( + label="Claude Code (legacy settings.json)", path=legacy, config_exists=True + ) + ) + + return out + + +def profile_from_path(path: str | os.PathLike) -> Profile: + """Build a Profile from a user-supplied config path (the 'override' case).""" + p = Path(path) + # Prefer the parent folder name as the label (e.g. .../Claude-Work/...json -> Claude-Work) + label = p.parent.name or p.name + return Profile(label=label, path=p, config_exists=p.is_file()) + + +# --------------------------------------------------------------------------- # +# Load / extract / apply +# --------------------------------------------------------------------------- # +def load_config(path: str | os.PathLike) -> dict: + """Read the full config dict. Missing/empty file -> {}. Bad JSON -> raises.""" + p = Path(path) + if not p.is_file(): + return {} + text = p.read_text(encoding="utf-8") + if not text.strip(): + return {} + obj = json.loads(text) # JSONDecodeError bubbles up for the GUI to display + if not isinstance(obj, dict): + raise ValueError("Top-level JSON in the config file is not an object.") + return obj + + +def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]: + """ + Attempt to repair a config file that failed strict parsing, WITHOUT writing + anything to disk. The caller decides what to do with the result (BCC shows + the user the issues and asks before saving). + + Returns (cfg, notes, repaired_text): + cfg - the parsed dict from the repaired JSON + notes - human-readable list of fixes that were applied + repaired_text - pretty-printed JSON the config would become + + Raises ValueError if the file can't be salvaged automatically. + """ + text = Path(path).read_text(encoding="utf-8") + candidate, notes = repair_json_text(text) + try: + obj = json.loads(candidate) + except json.JSONDecodeError as e: + raise ValueError( + f"Couldn't repair the file automatically (line {e.lineno}, column {e.colno}: {e.msg})." + ) from e + if not isinstance(obj, dict): + raise ValueError("Even after repair, the top level isn't a JSON object.") + pretty = json.dumps(obj, indent=2, ensure_ascii=False) + "\n" + return obj, notes, pretty + + +def extract_servers(cfg: dict) -> list[ServerEntry]: + """Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" + out: list[ServerEntry] = [] + for name, data in (cfg.get("mcpServers") or {}).items(): + out.append(ServerEntry(name=name, data=dict(data), enabled=True)) + for name, data in (cfg.get(DISABLED_KEY) or {}).items(): + out.append(ServerEntry(name=name, data=dict(data), enabled=False)) + return out + + +def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: + """ + Write the server list back into `cfg` in place, preserving every other key + and the position of `mcpServers`. Returns the same dict for convenience. + """ + enabled = {s.name: s.data for s in servers if s.enabled} + disabled = {s.name: s.data for s in servers if not s.enabled} + + cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise + if disabled: + cfg[DISABLED_KEY] = disabled + else: + cfg.pop(DISABLED_KEY, None) + return cfg + + +# --------------------------------------------------------------------------- # +# Write (atomic, with rotating backups) +# --------------------------------------------------------------------------- # +def _make_backup(path: Path) -> Path: + bdir = path.parent / BACKUP_DIRNAME + bdir.mkdir(exist_ok=True) + stamp = time.strftime("%Y%m%d-%H%M%S") + dest = bdir / f"{path.stem}.{stamp}.json" + # Avoid clobbering a same-second backup + n = 1 + while dest.exists(): + dest = bdir / f"{path.stem}.{stamp}.{n}.json" + n += 1 + shutil.copy2(path, dest) + # Prune oldest beyond MAX_BACKUPS + backups = sorted(bdir.glob(f"{path.stem}.*.json")) + for old in backups[:-MAX_BACKUPS]: + with contextlib.suppress(OSError): + old.unlink() + return dest + + +def write_config(path: str | os.PathLike, cfg: dict) -> Path | None: + """ + Atomically write `cfg` to `path` (2-space pretty JSON). Backs up any existing + file first. Returns the backup path (or None if there was nothing to back up). + """ + p = Path(path) + p.parent.mkdir(parents=True, exist_ok=True) + + backup = _make_backup(p) if p.is_file() else None + + payload = json.dumps(cfg, indent=2, ensure_ascii=False) + "\n" + fd, tmp = tempfile.mkstemp(dir=str(p.parent), prefix=".tmp_mcp_", suffix=".json") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(payload) + os.replace(tmp, p) # atomic on the same filesystem + finally: + if os.path.exists(tmp): + os.remove(tmp) + return backup + + +# --------------------------------------------------------------------------- # +# Backup utility functions +# --------------------------------------------------------------------------- # +_BACKUP_TS_RE = re.compile(r"(\d{8}-\d{6})") + + +def list_backups(config_path: Path | str) -> list[Path]: + """Return backup files for config_path, newest-first. Returns [] if none exist.""" + p = Path(config_path) + bdir = p.parent / BACKUP_DIRNAME + if not bdir.is_dir(): + return [] + return sorted(bdir.glob(f"{p.stem}.*.json"), reverse=True) + + +def backup_label(backup_path: Path | str) -> str: + """Human-readable label derived from the backup filename timestamp.""" + m = _BACKUP_TS_RE.search(Path(backup_path).name) + if not m: + return Path(backup_path).name + ts = m.group(1) # e.g. "20260702-004124" + return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}" + + +def _redact_server_data(data: dict) -> dict: + """Return a copy of a server definition with secrets masked for display.""" + out = dict(data) + if "args" in out: + out["args"] = redact_args(list(out["args"] or [])) + if "env" in out: + out["env"] = {k: (MASK if is_secret_key(k) else v) for k, v in (out["env"] or {}).items()} + return out + + +def _redact_servers_block(block: dict | None) -> dict: + """Return a sanitized copy of a mcpServers / _disabledMcpServers block.""" + if not block: + return {} + return {name: _redact_server_data(data) for name, data in block.items()} + + +def _server_sections(cfg: dict) -> dict: + """Return the masked server sections of a config dict, safe for diff display.""" + out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))} + if DISABLED_KEY in cfg: + out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY)) + return out + + +def backup_diff( + config_path: Path | str, + backup_path: Path | str, + current_cfg: dict | None = None, +) -> str: + """ + Unified diff showing what the server sections would look like after restoring + the backup. Only mcpServers and _disabledMcpServers are compared; non-server + keys are excluded entirely (they are preserved by restore, not changed). + Secret values in args and env are masked so the diff is safe to share. + + fromfile = current state, tofile = what will be written after restore. + Pass current_cfg to diff against an already-loaded in-memory config instead + of re-reading from disk. + """ + p = Path(config_path) + bp = Path(backup_path) + + if current_cfg is None: + try: + current_cfg = load_config(p) + except Exception: + current_cfg = {} + + backup_servers = extract_servers(load_config(bp)) + after_cfg = dict(current_cfg) + apply_servers(after_cfg, backup_servers) + + before_lines = ( + json.dumps(_server_sections(current_cfg), indent=2, ensure_ascii=False) + "\n" + ).splitlines(keepends=True) + after_lines = ( + json.dumps(_server_sections(after_cfg), indent=2, ensure_ascii=False) + "\n" + ).splitlines(keepends=True) + + result = "".join( + difflib.unified_diff( + before_lines, + after_lines, + fromfile=f"current: {p.name}", + tofile=f"restore: {bp.name}", + ) + ) + return result or "(no differences — backup matches current servers)" + + +def restore_backup( + config_path: Path | str, + backup_path: Path | str, + current_cfg: dict | None = None, +) -> Path | None: + """ + Restore server entries from backup_path into config_path. + + Only mcpServers and _disabledMcpServers are replaced; all other keys in the + current config (conversation history, project state, etc.) are preserved + verbatim and in their original order. + + Goes through write_config() so a pre-restore backup of the current file is + created automatically. Returns that backup path (or None if no prior file). + """ + p = Path(config_path) + if current_cfg is None: + try: + current_cfg = load_config(p) + except Exception: + current_cfg = {} + + backup_servers = extract_servers(load_config(Path(backup_path))) + cfg = dict(current_cfg) + apply_servers(cfg, backup_servers) + return write_config(p, cfg) + + +def config_mtime(path: Path | str) -> float | None: + """Return the file's mtime, or None if the file does not exist.""" + try: + return Path(path).stat().st_mtime + except OSError: + return None + + +class ConfigStat(NamedTuple): + """A snapshot of a config file's mtime + size. + + Pairing size with mtime hardens stale-file detection beyond bare mtime + equality: a concurrent external write can land within the filesystem's + mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime + restored by the writing process, in which case mtime alone would miss the + change. Comparing both fields catches those cases without the cost of a + full content hash. + """ + + mtime: float + size: int + + +def config_fingerprint(path: Path | str) -> ConfigStat | None: + """Return the file's (mtime, size) snapshot, or None if it does not exist.""" + try: + st = Path(path).stat() + except OSError: + return None + return ConfigStat(st.st_mtime, st.st_size) + + +def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: + """ + Compare original_cfg (what BCC loaded) with the current on-disk state. + + Returns: + changed_keys — sorted list of top-level keys whose values differ. + server_diff — masked unified diff of server sections (empty if unchanged + or the file cannot be read). + """ + try: + disk_cfg = load_config(Path(path)) + except Exception: + return [], "" + + all_keys = set(original_cfg) | set(disk_cfg) + changed_keys = sorted(k for k in all_keys if original_cfg.get(k) != disk_cfg.get(k)) + + server_diff = "" + if any(k in {"mcpServers", DISABLED_KEY} for k in changed_keys): + before_lines = ( + json.dumps(_server_sections(original_cfg), indent=2, ensure_ascii=False) + "\n" + ).splitlines(keepends=True) + after_lines = ( + json.dumps(_server_sections(disk_cfg), indent=2, ensure_ascii=False) + "\n" + ).splitlines(keepends=True) + server_diff = "".join( + difflib.unified_diff(before_lines, after_lines, fromfile="loaded", tofile="on disk now") + ) + + return changed_keys, server_diff + + +# --------------------------------------------------------------------------- # +# Paste / import parsing +# --------------------------------------------------------------------------- # +def looks_like_server(data) -> bool: + return isinstance(data, dict) and ("command" in data or "url" in data) + + +def suggest_name(data: dict) -> str: + """Derive a friendly default name from a server definition.""" + if "url" in data and "command" not in data: + host = urlparse(data["url"]).hostname or "remote" + return host.split(".")[0] or "remote" + for a in data.get("args", []) or []: + if isinstance(a, str) and ("/" in a or a.startswith("@")): + base = a.rstrip("/").split("/")[-1] + base = base.replace("server-", "").replace("mcp-server-", "").replace("mcp-", "") + if base: + return base + cmd = data.get("command", "server") + return Path(str(cmd)).stem or "server" + + +# --------------------------------------------------------------------------- # +# Lenient JSON repair +# +# Snippets pasted from MCP docs, blog posts, and chat windows are frequently +# not valid JSON: markdown fences, surrounding prose, // comments, trailing +# commas, smart quotes, single quotes, unquoted keys, missing braces. The +# whole point of BCC is that nobody should have to hand-fix JSON, so the +# paste pipeline repairs what it can and reports what it changed. +# --------------------------------------------------------------------------- # + +# Word-processor / web artifacts mapped back to ASCII. +_QUOTE_MAP = { + "“": '"', + "”": '"', + "„": '"', + "«": '"', + "»": '"', + "‘": "'", + "’": "'", + "‚": "'", +} +_JUNK_CHARS = "​‌‍" # zero-width chars / BOM + + +def _strip_fences(text: str, notes: list[str]) -> str: + """Pull the contents out of a ```json ... ``` fence (or drop stray fence lines).""" + m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE) + if m: + notes.append("extracted code from markdown fence") + return m.group(1) + if "```" in text: + notes.append("removed markdown fence markers") + return text.replace("```json", "").replace("```", "") + return text + + +def _normalize_unicode(text: str, notes: list[str]) -> str: + out = text + for junk in _JUNK_CHARS: + out = out.replace(junk, "") + out = out.replace(chr(0xA0), " ") # non-breaking space (defensive: avoid a literal char here) + for smart, ascii_q in _QUOTE_MAP.items(): + out = out.replace(smart, ascii_q) + if out != text: + notes.append("normalized smart quotes / invisible characters") + return out + + +def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]: + """ + One careful pass over the text producing (is_string, segment) pairs. + While walking it also: converts single-quoted strings to double-quoted, + and drops //, /* */ and # comments (never inside strings). + """ + segs: list[tuple[bool, str]] = [] + buf: list[str] = [] + i, n = 0, len(text) + + def flush(): + if buf: + segs.append((False, "".join(buf))) + buf.clear() + + while i < n: + ch = text[i] + if ch == '"': # proper double-quoted string: copy verbatim + flush() + j = i + 1 + s = ['"'] + while j < n: + c = text[j] + s.append(c) + if c == "\\" and j + 1 < n: + s.append(text[j + 1]) + j += 2 + continue + if c == '"': + j += 1 + break + j += 1 + segs.append((True, "".join(s))) + i = j + elif ch == "'": # single-quoted string: convert to double-quoted + flush() + j = i + 1 + inner: list[str] = [] + while j < n: + c = text[j] + if c == "\\" and j + 1 < n: + nxt = text[j + 1] + if nxt == "'": + inner.append("'") # \' has no meaning in JSON + else: + inner.append(c) + inner.append(nxt) + j += 2 + continue + if c == "'": + j += 1 + break + if c == '"': + inner.append('\\"') + j += 1 + continue + inner.append(c) + j += 1 + segs.append((True, '"' + "".join(inner) + '"')) + notes.append("converted single-quoted strings") + i = j + elif ch == "/" and i + 1 < n and text[i + 1] == "/": + notes.append("removed // comments") + while i < n and text[i] != "\n": + i += 1 + elif ch == "/" and i + 1 < n and text[i + 1] == "*": + notes.append("removed /* */ comments") + i += 2 + while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): + i += 1 + i = min(i + 2, n) + elif ch == "#": + # Only treat as a comment at line start (after whitespace) — never + # mid-value, where # could be part of an unquoted token. + line_start = text.rfind("\n", 0, i) + 1 + if text[line_start:i].strip() == "": + notes.append("removed # comments") + while i < n and text[i] != "\n": + i += 1 + else: + buf.append(ch) + i += 1 + else: + buf.append(ch) + i += 1 + flush() + return segs + + +def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str: + """Structural fixes that only apply outside strings.""" + fixed: list[str] = [] + for idx, (is_str, seg) in enumerate(segs): + if is_str: + fixed.append(seg) + continue + s = seg + # Python / JS literals -> JSON + s2 = re.sub(r"\bTrue\b", "true", s) + s2 = re.sub(r"\bFalse\b", "false", s2) + s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2) + if s2 != s: + notes.append("converted Python/JS literals (True/False/None)") + s = s2 + # Unquoted keys: { key: or , key: -> "key": + s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s) + if s2 != s: + notes.append("quoted bare object keys") + s = s2 + # Missing comma before the next string on a new line, e.g. + # "args": ["x"]\n"env": {...} or {...}\n"name2": {...} + # A string directly after a value-ish ending across a newline can never + # be valid JSON without a comma, so inserting one is always safe. + if idx + 1 < len(segs) and segs[idx + 1][0]: + body = s.rstrip() + tail_ws = s[len(body) :] + prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "") + value_ending = prev in ('"', "}", "]") or prev.isdigit() + if "\n" in tail_ws and value_ending: + notes.append("inserted missing commas") + s = body + "," + tail_ws + fixed.append(s) + + out = "".join(fixed) + # Trailing commas (safe now: strings are intact, commas here are structural) + segs2 = _tokenize(out, []) + parts: list[str] = [] + changed = False + for is_str, seg in segs2: + if is_str: + parts.append(seg) + else: + new = re.sub(r",(\s*[}\]])", r"\1", seg) + changed = changed or new != seg + parts.append(new) + if changed: + notes.append("removed trailing commas") + return "".join(parts) + + +def _extract_and_balance(text: str, notes: list[str]) -> str: + """Slice out the JSON object (dropping surrounding prose) and close any + unclosed braces/brackets.""" + start = text.find("{") + if start == -1: + return text + if text[:start].strip(): + notes.append("ignored text before the JSON block") + + stack: list[str] = [] + i, n = start, len(text) + end = -1 + while i < n: + ch = text[i] + if ch == '"': # skip strings + i += 1 + while i < n: + if text[i] == "\\": + i += 2 + continue + if text[i] == '"': + break + i += 1 + elif ch in "{[": + stack.append("}" if ch == "{" else "]") + elif ch in "}]": + if stack and stack[-1] == ch: + stack.pop() + if not stack: + end = i + 1 + break + i += 1 + + if end != -1: + if text[end:].strip(): + notes.append("ignored text after the JSON block") + return text[start:end] + # Ran out of input with open scopes: close them. + if stack: + notes.append("closed unclosed braces/brackets") + return text[start:] + "".join(reversed(stack)) + return text[start:] + + +def repair_json_text(text: str) -> tuple[str, list[str]]: + """ + Best-effort repair of an almost-JSON snippet. Returns (candidate, notes) + where notes is a human-readable list of the fixes applied (deduplicated, + in order). Does NOT guarantee the result parses — callers still try + json.loads and surface its error if repair wasn't enough. + """ + notes: list[str] = [] + t = _strip_fences(text, notes) + t = _normalize_unicode(t, notes) + t = t.strip() + + # Brace-less inner fragment: "name": { ... } (with no outer braces). + # A quoted key at the start is a strong signal; a bare word only counts + # when followed by '{' so prose like "Note: ..." isn't swallowed. + if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t): + notes.append("wrapped fragment in braces") + t = "{" + t + "}" + + t = _extract_and_balance(t, notes) + segs = _tokenize(t, notes) + t = _repair_segments(segs, notes) + + # Re-balance in case comment/quote fixes exposed structure. + t = _extract_and_balance(t, []) + + seen: set[str] = set() + unique = [x for x in notes if not (x in seen or seen.add(x))] + return t, unique + + +def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]: + """ + Like parse_pasted_json, but forgiving. Tries strict JSON first; if that + fails, runs repair_json_text() and retries. Returns (servers, repair_notes). + repair_notes is empty when the input was already valid. + """ + raw = (text or "").strip() + if not raw: + raise ValueError("Nothing to parse.") + try: + return _shape_servers(json.loads(raw)), [] + except json.JSONDecodeError as strict_err: + candidate, notes = repair_json_text(raw) + try: + obj = json.loads(candidate) + except json.JSONDecodeError: + # Repair wasn't enough — report the original, more meaningful error. + raise ValueError( + f"Couldn't parse that as JSON even after auto-repair " + f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})." + ) from strict_err + return _shape_servers(obj), notes + + +def _shape_servers(obj) -> dict[str, dict]: + """Shared shape-detection for parsed paste content.""" + if not isinstance(obj, dict): + raise ValueError("Top-level JSON must be an object ({ ... }).") + + if isinstance(obj.get("mcpServers"), dict): + servers = obj["mcpServers"] + elif looks_like_server(obj): + servers = {suggest_name(obj): obj} + elif obj and all(isinstance(v, dict) for v in obj.values()): + servers = obj + else: + raise ValueError("Couldn't find any MCP server definitions in that JSON.") + + clean: dict[str, dict] = {} + for name, data in servers.items(): + if not looks_like_server(data): + raise ValueError( + f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')." + ) + clean[str(name)] = data + return clean + + +def parse_pasted_json(text: str) -> dict[str, dict]: + """ + Accept any of the shapes MCP docs hand out and return {name: server_dict}: + + 1. Full config: {"mcpServers": {"name": {...}}} + 2. Inner block only: {"name": {"command": ...}} + 3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) + + Input doesn't have to be valid JSON — markdown fences, comments, trailing + commas, smart/single quotes, unquoted keys, surrounding prose, and missing + braces are repaired automatically (see repair_json_text). + + Raises ValueError with a human message on anything unrecognizable. + """ + servers, _notes = parse_pasted_json_verbose(text) + return servers + + +# --------------------------------------------------------------------------- # +# Secrets: detection + redaction +# --------------------------------------------------------------------------- # +_SECRET_KEY_RE = re.compile( + r"(?i)(token|secret|passw|api[-_]?key|apikey|auth|credential|bearer|private[-_]?key|access[-_]?key)" +) + +# Well-known token prefixes that identify a bare value as a secret even +# without a telling key/flag name next to it. +_TOKEN_PREFIXES = ( + "ghp_", + "gho_", + "ghu_", + "ghs_", + "github_pat_", # GitHub + "sk-", + "sk_live_", + "sk_test_", + "rk_live_", # OpenAI / Stripe + "xoxb-", + "xoxp-", + "xoxc-", + "xoxs-", + "xapp-", # Slack + "glpat-", + "gldt-", # GitLab + "AKIA", + "ASIA", # AWS access key IDs + "ya29.", + "AIza", # Google + "pypi-", + "npm_", + "dop_v1_", + "figd_", +) + +MASK = "••••••••" + +# Matches userinfo credentials embedded in a URL: scheme://user:pass@host +# Fires on postgres://user:pass@host but NOT on https://host/path or ssh://user@host. +_EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@") + + +def is_secret_key(name: str) -> bool: + """Does this env-var / header / flag name look like it holds a secret?""" + return bool(_SECRET_KEY_RE.search(name or "")) + + +def _is_secret_value(value: str) -> bool: + return isinstance(value, str) and value.startswith(_TOKEN_PREFIXES) + + +def redact_args(args: list[str]) -> list[str]: + """ + Mask secret values in an args list for display/diagnostics: + --token abc123 -> --token •••••••• (value after a secret flag) + --api-key=abc123 -> --api-key=•••••••• (inline flag=value) + ghp_abc123 -> •••••••• (well-known token prefix) + Everything else passes through untouched. + """ + out: list[str] = [] + mask_next = False + for a in args: + s = str(a) + if mask_next: + out.append(MASK) + mask_next = False + continue + if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]): + out.append(s.split("=", 1)[0] + "=" + MASK) + continue + if s.startswith("-") and is_secret_key(s): + out.append(s) + mask_next = True + continue + if _is_secret_value(s): + out.append(MASK) + continue + out.append(s) + return out + + +def args_secret_warning(data: dict) -> str | None: + """ + Return a warning string when any arg looks like a raw secret that would + be better placed in `env`. Returns None when no concern is found. + + Skips --flag=value inline pairs (already partially self-documenting). + Fires on: + - positional values that start with a well-known token prefix (ghp_, sk-, …) + - values that follow a secret-named flag (--token abc, --api-key abc) + - URLs with embedded user:pass credentials (postgres://user:pass@host) + """ + args = [str(a) for a in (data.get("args") or [])] + mask_next = False + for a in args: + if mask_next: + mask_next = False + if not a.startswith("-"): + return ( + "An arg value following a secret-named flag looks like a credential. " + "Where the server supports it, prefer Environment variables — " + "args are visible in process listings." + ) + continue + # --flag=value inline: skip (the flag name already labels it) + if a.startswith("-") and "=" in a: + continue + # --secretflag (no inline value): flag the next positional arg + if a.startswith("-") and is_secret_key(a): + mask_next = True + continue + if a.startswith("-"): + continue + # Positional value: check for token prefix or embedded URL credentials + if _is_secret_value(a) or _EMBEDDED_CRED_RE.search(a): + return ( + "An arg value looks like a credential. " + "Where the server supports it, prefer Environment variables — " + "args are visible in process listings." + ) + return None + + +def split_suspicious_args(args: list[str]) -> tuple[list[str], list[str]]: + """ + Detect the classic argument-entry mistake: several argv tokens typed on one + line ("--directory /path/to/server"). An entry is only flagged when it + contains whitespace AND at least one whitespace-separated token starts with + "-" — so legitimate single arguments with spaces ("My Project Notes", + "/Users/me/My Documents") are never touched. + + Returns (fixed_args, notes). notes is empty when nothing was suspicious; + otherwise it describes each split so a UI can show the proposed fix. + Quotes are respected when splitting ('--name "My Server"' becomes + ['--name', 'My Server']). + """ + import shlex + + out: list[str] = [] + notes: list[str] = [] + for a in args: + if isinstance(a, str) and _looks_like_multiple_args(a): + try: + parts = shlex.split(a) + except ValueError: # unbalanced quotes — fall back to plain split + parts = a.split() + if len(parts) > 1: + out.extend(parts) + notes.append(f"“{a}” looks like {len(parts)} arguments — split onto separate lines") + continue + out.append(a) + return out, notes + + +def _looks_like_multiple_args(a: str) -> bool: + toks = a.split() + return len(toks) > 1 and any(t.startswith("-") for t in toks) + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # +def validate_servers(servers: list[ServerEntry]) -> list[str]: + """Return a list of human-readable problems. Empty list == all good.""" + problems: list[str] = [] + seen: dict[str, int] = {} + for s in servers: + nm = s.name.strip() + if not nm: + problems.append("A server has an empty name.") + seen[nm] = seen.get(nm, 0) + 1 + if s.kind == "stdio": + if not str(s.data.get("command", "")).strip(): + problems.append(f"'{nm or '(unnamed)'}' is a local server but has no command.") + else: + url = str(s.data.get("url", "")).strip() + if not url: + problems.append(f"'{nm or '(unnamed)'}' is a remote server but has no URL.") + elif not (url.startswith("http://") or url.startswith("https://")): + problems.append(f"'{nm}' has a URL that isn't http(s).") + for nm, count in seen.items(): + if nm and count > 1: + problems.append(f"Duplicate server name: '{nm}' ({count}x).") + return problems + + +# --------------------------------------------------------------------------- # +# Dependency / PATH checking +# --------------------------------------------------------------------------- # +@functools.lru_cache(maxsize=1) +def system_path() -> str: + """ + The PATH that a GUI app (e.g., Claude Desktop launched from Finder/dock) + reliably inherits, independent of how BCC itself was started. + + This is used to determine the 'ok' vs 'warn' distinction: + ok = found in system_path() → works however Claude is launched + warn = found only in augmented_path() → works from a terminal but may + not work when Claude is opened from the desktop + + macOS : /etc/paths + /etc/paths.d/* (what launchd provides) plus + well-known package-manager prefixes (/opt/homebrew, /opt/local). + Windows: system + user PATH from the registry. + Linux : /etc/environment + standard FHS dirs + /snap/bin. + """ + dirs: list[str] = [] + + if sys.platform == "darwin": + for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]: + with contextlib.suppress(OSError): + dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip()) + # Package-manager install prefixes: present on disk once installed, + # accessible to all processes regardless of shell configuration. + for d in ( + "/opt/homebrew/bin", + "/opt/homebrew/sbin", # Homebrew (Apple Silicon) + "/usr/local/bin", + "/usr/local/sbin", # Homebrew (Intel) / manual installs + "/opt/local/bin", + "/opt/local/sbin", # MacPorts + ): + dirs.append(d) + + elif os.name == "nt": + try: + import winreg + + for hive, sub in [ + ( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ), + (winreg.HKEY_CURRENT_USER, r"Environment"), + ]: + try: + with winreg.OpenKey(hive, sub) as k: + val, _ = winreg.QueryValueEx(k, "PATH") + dirs.extend(os.path.expandvars(val).split(os.pathsep)) + except OSError: + pass + except ImportError: + pass + dirs.extend( + [ + "C:\\Windows\\System32", + "C:\\Windows", + "C:\\Windows\\System32\\Wbem", + os.path.expandvars(r"%ProgramFiles%\nodejs"), + ] + ) + + else: # Linux / other + try: + for line in Path("/etc/environment").read_text().splitlines(): + if line.upper().startswith("PATH="): + dirs.extend(line[5:].strip("\"'").split(os.pathsep)) + except OSError: + pass + for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"): + dirs.append(d) + + seen, out = set(), [] + for d in dirs: + d = d.strip() + if d and d not in seen: + seen.add(d) + out.append(d) + return os.pathsep.join(out) + + +@functools.lru_cache(maxsize=1) +def augmented_path() -> str: + """ + The widest PATH BCC searches when looking for a command — system_path() + plus user-specific runtime locations (nvm, cargo, volta, bun, etc.) that + require shell configuration to be active. + + Memoized for the session. Call refresh_path_cache() to re-scan, e.g. + after the user installs a new runtime. + """ + base = system_path().split(os.pathsep) + # Also include the current process PATH (catches unusual CI / container setups) + env_parts = os.environ.get("PATH", "").split(os.pathsep) + home = Path.home() + user_dirs = [ + str(home / ".local" / "bin"), + str(home / ".cargo" / "bin"), + str(home / ".deno" / "bin"), + str(home / ".bun" / "bin"), + str(home / ".volta" / "bin"), + ] + user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) + user_dirs += glob.glob( + str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin") + ) + if os.name == "nt": + appdata = os.environ.get("APPDATA", "") + if appdata: + user_dirs.append(str(Path(appdata) / "npm")) + + seen, ordered = set(), [] + for p in base + env_parts + user_dirs: + if p and p not in seen and Path(p).is_dir(): + seen.add(p) + ordered.append(p) + return os.pathsep.join(ordered) + + +def refresh_path_cache(): + """Forget memoized PATHs so the next check re-scans (e.g. after an install).""" + system_path.cache_clear() + augmented_path.cache_clear() + + +# Tailored install advice keyed by the runner's basename. +RUNNER_HINTS = { + "npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. " + "With nvm, the binary lives at ~/.nvm/versions/node//bin.", + "node": "Node.js not found. Install from nodejs.org or `brew install node`.", + "uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " + "(or `brew install uv`). uvx ships inside uv.", + "uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " + "(or `brew install uv`).", + "python": "Python not found on this PATH.", + "python3": "Python 3 not found on this PATH.", + "docker": "Docker not found. Install Docker Desktop and make sure it's running.", + "bun": "Bun not found. Install with `curl -fsSL https://bun.sh/install | bash`.", + "deno": "Deno not found. Install with `curl -fsSL https://deno.land/install.sh | sh`.", +} + + +def runner_hint(cmd: str) -> str: + base = Path(cmd).name.lower() + if base.endswith(".exe"): + base = base[:-4] + if base in RUNNER_HINTS: + return RUNNER_HINTS[base] + if ("/" in cmd) or ("\\" in cmd): + return ( + f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " + f"Check the path and that it's marked executable (chmod +x)." + ) + return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'." + + +SHELL_WRAPPERS = {"cmd", "cmd.exe", "sh", "bash", "zsh", "powershell", "powershell.exe", "pwsh"} + + +def _commands_to_check(data: dict) -> list[str]: + cmd = data.get("command") + if not cmd: + return [] + cmds = [str(cmd)] + base = Path(str(cmd)).name.lower() + if base in SHELL_WRAPPERS: + for a in data.get("args", []) or []: + if isinstance(a, str) and not a.startswith(("/", "-")) and " " not in a: + cmds.append(a) + break + return cmds + + +def check_dependency(data: dict, path: str | None = None) -> dict: + """ + Decide whether a server's command can actually be found, and gather enough + context to troubleshoot when it can't. + + status is one of: + 'ok' - resolved on the normal (inherited) PATH; will work anywhere. + 'warn' - resolved, but ONLY via an augmented location. Works in a + terminal launch; a bundled .app launch of Claude may not see it. + 'missing' - not found anywhere. + 'remote' - a url-based server (no local command to check). + 'unknown' - no command set. + + Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail} + """ + if "url" in data and "command" not in data: + return { + "status": "remote", + "label": "remote endpoint", + "detail": data.get("url", ""), + "resolved": {}, + "in_base": {}, + "searched_path": "", + "hints": [], + "commands": [], + } + + cmds = _commands_to_check(data) + if not cmds: + return { + "status": "unknown", + "label": "no command set", + "detail": "", + "resolved": {}, + "in_base": {}, + "searched_path": "", + "hints": ["This server has no 'command' to run."], + "commands": [], + } + + aug = path or augmented_path() + resolved: dict[str, str | None] = {} + in_base: dict[str, bool] = {} + for c in cmds: + if (os.sep in c) or ("/" in c): # explicit path, independent of PATH + cp = Path(c) + ok = cp.exists() and os.access(cp, os.X_OK) + resolved[c] = str(cp) if ok else None + in_base[c] = ok + else: + resolved[c] = shutil.which(c, path=aug) + # 'in_base' means: found in system_path() — the PATH that Claude + # Desktop (or any GUI app) reliably inherits regardless of shell config. + # This check is consistent whether BCC runs from a terminal or as a .app. + in_base[c] = bool(shutil.which(c, path=system_path())) + + missing = [c for c, r in resolved.items() if not r] + hints: list[str] = [] + + if missing: + for c in missing: + hints.append(runner_hint(c)) + status = "missing" + label = "missing: " + ", ".join(missing) + else: + nonbase = [c for c in resolved if not in_base[c]] + if nonbase: + status = "warn" + label = "found, but only on a non-standard PATH" + for c in nonbase: + hints.append( + f"'{c}' was found at {resolved[c]}, but that location is not in the standard " + f"system PATH. Claude Desktop launched from Finder or the dock may not see it. " + f"Clicking 'Use full path ↳' rewrites the command to its absolute path, which " + f"works regardless of how Claude is launched." + ) + else: + status = "ok" + label = "found: " + ", ".join(Path(p).name for p in resolved.values()) + + return { + "status": status, + "label": label, + "resolved": resolved, + "in_base": in_base, + "searched_path": aug, + "hints": hints, + "commands": cmds, + "detail": "", + } + + +def diagnostics_text(name: str, data: dict) -> str: + """A copy-pasteable plain-text troubleshooting report for one server.""" + res = check_dependency(data) + L: list[str] = [] + if res["status"] == "remote": + L.append(f"Server: {name or '(unnamed)'} [remote]") + L.append(f"URL: {data.get('url', '')}") + if data.get("type"): + L.append(f"Transport: {data['type']}") + if data.get("headers"): + L.append("Headers: " + ", ".join(data["headers"].keys())) + L.append("Status: REMOTE (network reachability is not checked)") + return "\n".join(L) + + L.append(f"Server: {name or '(unnamed)'} [local / stdio]") + L.append(f"Command: {data.get('command', '')}") + if data.get("args"): + # Redacted: this report is designed to be pasted into bug reports. + L.append("Args: " + " ".join(redact_args(list(data["args"])))) + if data.get("env"): + L.append("Env keys: " + ", ".join(data["env"].keys())) + L.append(f"Status: {res['status'].upper()}") + L.append("") + L.append("Command resolution:") + for c, r in res["resolved"].items(): + if r: + tag = "" if res["in_base"].get(c) else " <-- non-standard PATH; app may not see it" + L.append(f" {c} -> {r}{tag}") + else: + L.append(f" {c} -> NOT FOUND") + if res["hints"]: + L.append("") + L.append("Hints:") + for h in res["hints"]: + L.append(f" - {h}") + + sys_dirs = set(system_path().split(os.pathsep)) + aug_dirs = res["searched_path"].split(os.pathsep) if res["searched_path"] else [] + extra = [d for d in aug_dirs if d not in sys_dirs] + L.append("") + L.append(f"PATH searched ({len(aug_dirs)} dirs):") + for d in aug_dirs: + L.append((" + " if d in extra else " ") + d) + if extra: + L.append("") + L.append( + "(+ = user-specific dirs not in the standard system PATH. " + "Claude Desktop launched from Finder may NOT have these.)" + ) + return "\n".join(L) + + +_STDERR_CAP = 4096 # bytes + + +def spawn_test(data: dict, timeout: float = 3.0) -> dict: + """ + Attempt to start a stdio server and observe it for `timeout` seconds. + + Returns a dict: + outcome: "ok" — still running after timeout; server started correctly + "exited" — exited with code 0 before timeout; unusual for a server + "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 + returncode: int | None + stderr: str (first ~4 KB) + detail: str + + Run this off the UI thread — it blocks for up to `timeout` seconds. + """ + if "url" in data and "command" not in data: + return { + "outcome": "not_applicable", + "returncode": None, + "stderr": "", + "detail": "remote server", + } + + cmd = (data.get("command") or "").strip() + if not cmd: + return { + "outcome": "not_applicable", + "returncode": None, + "stderr": "", + "detail": "no command set", + } + + # Resolve to absolute path so subprocess doesn't fight with PATH in env. + resolved_cmd = shutil.which(cmd, path=augmented_path()) + if resolved_cmd is None: + return { + "outcome": "not_found", + "returncode": None, + "stderr": "", + "detail": f"command not found: {cmd}", + } + + 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 {}) + + stderr_chunks: list[bytes] = [] + + def _drain(pipe) -> None: + # Read until EOF so the pipe buffer never fills and blocks the subprocess. + # Only keep the first _STDERR_CAP bytes; the rest is discarded. + captured = 0 + try: + while True: + chunk = pipe.read(1024) + if not chunk: + break + if captured < _STDERR_CAP: + keep = min(len(chunk), _STDERR_CAP - captured) + stderr_chunks.append(chunk[:keep]) + captured += keep + except Exception: + pass + + popen_kwargs: dict = dict( + stdin=subprocess.PIPE, # keep open so servers block on read rather than seeing EOF + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + env=merged_env, + ) + if os.name != "nt": + popen_kwargs["start_new_session"] = True # own process group → clean kill + + try: + proc = subprocess.Popen(args_list, **popen_kwargs) + except (FileNotFoundError, OSError) as e: + return { + "outcome": "not_found", + "returncode": None, + "stderr": "", + "detail": str(e), + } + + drain_thread = threading.Thread(target=_drain, args=(proc.stderr,), daemon=True) + drain_thread.start() + + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + try: + if os.name != "nt": + os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) + else: + proc.kill() # best-effort on Windows + except OSError: + pass + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=1.0) + drain_thread.join(0.5) + stderr = b"".join(stderr_chunks).decode(errors="replace") + return { + "outcome": "ok", + "returncode": None, + "stderr": stderr, + "detail": f"still running after {timeout:.0f}s — server started successfully", + } + + drain_thread.join(0.5) + stderr = b"".join(stderr_chunks).decode(errors="replace") + rc = proc.returncode + if rc == 0: + return { + "outcome": "exited", + "returncode": rc, + "stderr": stderr, + "detail": "process exited cleanly (code 0) — unusual; a healthy server should keep running", + } + return { + "outcome": "crashed", + "returncode": rc, + "stderr": stderr, + "detail": f"process exited with code {rc}", + } + + +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) + counts as reachable; only connection/DNS/timeout failures count as unreachable. + Returns (ok, detail). Network call — run this off the UI thread. + """ + import socket + import urllib.error + import urllib.request + + url = (url or "").strip() + if not (url.startswith("http://") or url.startswith("https://")): + return False, "not an http(s) URL" + req = urllib.request.Request( + url, + method="GET", + headers={ + "Accept": "text/event-stream, application/json, */*", + "User-Agent": "BetterClaudeConfig/1.0", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + return True, f"HTTP {r.status}" + except urllib.error.HTTPError as e: + return True, f"HTTP {e.code} (reachable)" + except urllib.error.URLError as e: + reason = getattr(e, "reason", e) + if isinstance(reason, socket.timeout): + return False, "timed out" + return False, str(reason) + except TimeoutError: + return False, "timed out" + except Exception as e: + return False, str(e) + + +def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | None]: + """ + Fix a 'PATH-risk' (warn) server by rewriting the bare runner name to the + absolute path it resolved to, so any process (incl. a bundled Claude.app) + can find it. Returns (new_data, note). note is None if nothing was pinned. + """ + res = check_dependency(data, path) + if res["status"] != "warn": + return data, None + out = dict(data) + for c, resolved in res["resolved"].items(): + if resolved and not res["in_base"].get(c, True) and ("/" not in c and "\\" not in c): + if str(out.get("command", "")) == c: + out["command"] = resolved + return out, f"command → {resolved}" + args = list(out.get("args", []) or []) + for i, a in enumerate(args): + if a == c: + args[i] = resolved + out["args"] = args + return out, f"'{c}' → {resolved}" + return data, None From 346d0aabb699d439385cc01f68818b5a34d7ad76 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:30:35 -0400 Subject: [PATCH 14/30] =?UTF-8?q?fix:=20use=20=C2=A0=20escape=20for=20non-?= =?UTF-8?q?breaking=20space=20(byte-exact=20transcription=20fix)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bcc_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index c3b3f46..e5bcc46 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -474,7 +474,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From fd2c3567a0dbbb22fce530646a0079c71b9869ab Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:32:14 -0400 Subject: [PATCH 15/30] feat: Restart Claude Desktop button after save (#9) After a successful save, shows a 'Restart Claude Desktop' button next to the status line. Only shown when the saved profile targets Claude Desktop (core.profile_targets_claude_desktop) -- never for Claude Code, which has no GUI process to bounce. Clicking it calls core.restart_claude_desktop() and reports success/failure. The button hides again on further edits or when switching/reloading profiles. --- bcc.py | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/bcc.py b/bcc.py index 599eaac..213f5a1 100644 --- a/bcc.py +++ b/bcc.py @@ -1135,9 +1135,19 @@ class MainWindow(QMainWindow): root.addLayout(self._build_actionbar()) + status_row = QHBoxLayout() + status_row.setContentsMargins(0, 0, 0, 0) self.status = QLabel("Ready.") self.status.setObjectName("statusbar") - root.addWidget(self.status) + status_row.addWidget(self.status, 1) + self.restart_btn = QPushButton("Restart Claude Desktop") + self.restart_btn.setToolTip( + "Quit and relaunch Claude Desktop so the saved config takes effect" + ) + self.restart_btn.clicked.connect(self._restart_claude_desktop) + self.restart_btn.hide() + status_row.addWidget(self.restart_btn) + root.addLayout(status_row) self._restore_layout() self.reload_profiles() @@ -1384,6 +1394,7 @@ class MainWindow(QMainWindow): self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False + self.restart_btn.hide() self._undo_stack.clear() self.undo_btn.setEnabled(False) self._refresh_tables(select_index=0 if self.servers else -1) @@ -1753,6 +1764,7 @@ class MainWindow(QMainWindow): f"Merged & saved {self.current_profile.path}{bnote}" f" · Restart {self.current_profile.label} to apply." ) + self._offer_restart_button() return # else OVERWRITE: fall through to normal write @@ -1770,6 +1782,29 @@ class MainWindow(QMainWindow): self.status.setText( f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply." ) + self._offer_restart_button() + + # --- restart Claude Desktop (issue #9) -------------------------------- # + 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): + 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.hide() + if result.success: + self.status.setText(f"{self.status.text()} · {result.detail}") + else: + QMessageBox.warning(self, "Restart failed", result.detail) def _restore_from_backup(self): if not self.current_profile: @@ -1798,6 +1833,7 @@ class MainWindow(QMainWindow): # --- dirty / status -------------------------------------------------- # def _mark_dirty(self): self.dirty = True + self.restart_btn.hide() self._validate() self._update_status(saved=False) From 0ffc6a1fb647e9ec5a7179639e6e6d47bf33983f Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:32:36 -0400 Subject: [PATCH 16/30] chore: restore trailing newline in bcc_core.py (attempt 2) --- bcc_core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bcc_core.py b/bcc_core.py index 1b33a66..db21ff7 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1565,3 +1565,4 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None + From f1935fe32057e585a69633fdae7c5fa177851565 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:33:38 -0400 Subject: [PATCH 17/30] feat: use mtime+size fingerprint for stale-file check (#17) MainWindow._loaded_mtime -> _loaded_stat (core.ConfigStat), populated via core.config_fingerprint() at load/save time. The save-path stale check now compares the full fingerprint (mtime AND size) instead of bare mtime equality. --- bcc.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/bcc.py b/bcc.py index 599eaac..2c58009 100644 --- a/bcc.py +++ b/bcc.py @@ -1104,7 +1104,7 @@ class MainWindow(QMainWindow): self.full_config: dict = {} self.servers: list[core.ServerEntry] = [] self.current_profile: core.Profile | None = None - self._loaded_mtime: float | None = None + self._loaded_stat: core.ConfigStat | None = None self.dirty = False self._suppress_table = False self._suppress_sel = False @@ -1380,7 +1380,7 @@ class MainWindow(QMainWindow): return self.full_config = cfg repaired = True - self._loaded_mtime = core.config_mtime(profile.path) + self._loaded_stat = core.config_fingerprint(profile.path) self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False @@ -1719,11 +1719,14 @@ class MainWindow(QMainWindow): return # Stale-file check: if the file changed on disk since we loaded it, prompt. - disk_mtime = core.config_mtime(self.current_profile.path) + # Compare mtime AND size (not mtime alone) so a concurrent external write + # that lands within the mtime resolution window, or that restores the + # original mtime, still gets caught. + disk_stat = core.config_fingerprint(self.current_profile.path) if ( - disk_mtime is not None - and self._loaded_mtime is not None - and disk_mtime != self._loaded_mtime + disk_stat is not None + and self._loaded_stat is not None + and disk_stat != self._loaded_stat ): changed_keys, server_diff = core.external_change_summary( self.full_config, self.current_profile.path @@ -1744,7 +1747,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Save failed", str(e)) return self.full_config = fresh - self._loaded_mtime = core.config_mtime(self.current_profile.path) + self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) @@ -1762,7 +1765,7 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return - self._loaded_mtime = core.config_mtime(self.current_profile.path) + self._loaded_stat = core.config_fingerprint(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) From 7c8fd6d0bbb0b29878e134335032ab19686157be Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:34:53 -0400 Subject: [PATCH 18/30] fix: use explicit backslash-u-0-0-a-0 escape for non-breaking space literal --- bcc_core.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index e5bcc46..84f1171 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -474,7 +474,8 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + NBSP = chr(0xA0) + out = out.replace(NBSP, " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From f9752211a21d3533e2d6c2405d9286323c6ee4c8 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:35:03 -0400 Subject: [PATCH 19/30] test: add coverage for config_fingerprint mtime+size hardening (#17) Includes the required regression case: rewrite a file with different content, force the original mtime back via os.utime, and assert the fingerprint still differs (because size changed). --- tests/test_core.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..bfc026e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,6 +2,7 @@ proper test functions with tmp_path/monkeypatch fixtures).""" import json +import os import sys import pytest @@ -520,6 +521,42 @@ def test_config_mtime_returns_none_for_missing_file(tmp_path): assert c.config_mtime(tmp_path / "nonexistent.json") is None +def test_config_fingerprint_returns_mtime_and_size_for_existing_file(tmp_path): + f = tmp_path / "cfg.json" + f.write_text("{}") + fp = c.config_fingerprint(f) + assert isinstance(fp, c.ConfigStat) + assert isinstance(fp.mtime, float) + assert fp.size == f.stat().st_size + + +def test_config_fingerprint_returns_none_for_missing_file(tmp_path): + assert c.config_fingerprint(tmp_path / "nonexistent.json") is None + + +def test_config_fingerprint_detects_size_change_when_mtime_is_unchanged(tmp_path): + """ + Guards against the exact hole bare-mtime comparison has: an external write + that lands within the filesystem's mtime resolution (or that restores the + original mtime) must still be caught, because the file's size differs. + """ + f = tmp_path / "cfg.json" + f.write_text('{"mcpServers": {}}') + original = c.config_fingerprint(f) + original_mtime_ns = f.stat().st_mtime_ns + + # Overwrite with materially different (larger) content, then force the + # mtime back to its original value -- simulating a same-second external + # write, or a writer that preserves mtime. + f.write_text('{"mcpServers": {"new-server": {"command": "node", "args": ["a", "b", "c"]}}}') + os.utime(f, ns=(original_mtime_ns, original_mtime_ns)) + + updated = c.config_fingerprint(f) + assert updated.mtime == original.mtime # mtime alone would say "unchanged" + assert updated.size != original.size # size catches what mtime missed + assert updated != original # the fingerprint as a whole detects the change + + def test_external_change_summary_detects_non_server_key_change(tmp_path): cfgpath = tmp_path / "cfg.json" original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}} From 85d47aea97ffdebc3dfd82d8a99b2c27dd040ed6 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:36:58 -0400 Subject: [PATCH 20/30] test: add server_log_path coverage (macOS, Windows, missing, unsupported) (#6) --- tests/test_core.py | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..5024c56 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -638,3 +638,49 @@ def test_args_secret_warning_env_not_triggered(): def test_args_secret_warning_empty(): assert c.args_secret_warning({}) is None assert c.args_secret_warning({"args": []}) is None + + +# --------------------------------------------------------------------------- # +# server_log_path (in-app log viewer — issue #6) +# --------------------------------------------------------------------------- # +def test_server_log_path_macos_when_file_exists(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + log_dir = tmp_path / "Library" / "Logs" / "Claude" + log_dir.mkdir(parents=True) + (log_dir / "mcp-server-brave-search.log").write_text("hello") + + result = c.server_log_path("brave-search") + assert result == log_dir / "mcp-server-brave-search.log" + + +def test_server_log_path_macos_none_when_missing(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "darwin") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + assert c.server_log_path("brave-search") is None + + +def test_server_log_path_windows_when_file_exists(tmp_path, monkeypatch): + # server_log_path branches on sys.platform only (never os.name), so the + # test doesn't have to touch os.name -- mutating that globally mid-test + # would also flip which concrete Path subclass pathlib hands out. + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path)) + log_dir = tmp_path / "Claude" / "logs" + log_dir.mkdir(parents=True) + (log_dir / "mcp.log").write_text("hello") + + result = c.server_log_path("brave-search") + assert result == log_dir / "mcp.log" + + +def test_server_log_path_windows_none_when_missing(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("APPDATA", str(tmp_path)) + assert c.server_log_path("brave-search") is None + + +def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch): + monkeypatch.setattr(sys, "platform", "linux") + monkeypatch.setattr(c.Path, "home", lambda: tmp_path) + assert c.server_log_path("brave-search") is None From 256827eaf320dd53430cff37dcc8df7d05545d17 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:38:35 -0400 Subject: [PATCH 21/30] fix: use \xa0 escape instead of a literal NBSP byte (transmission-safe, same behavior); drop stray trailing blank line --- bcc_core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index db21ff7..2cbe54c 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -580,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace("\xa0", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1565,4 +1565,3 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None - From 2b3843a7140b14244f83bca873786a7b572d3666 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:40:32 -0400 Subject: [PATCH 22/30] feat: add View logs button + LogViewerDialog for in-app MCP log viewer (#6) --- bcc.py | 130 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/bcc.py b/bcc.py index 599eaac..6423110 100644 --- a/bcc.py +++ b/bcc.py @@ -374,6 +374,9 @@ class ServerEditor(QFrame): self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") self.spawn_btn.clicked.connect(self._test_spawn) self.spawn_btn.setVisible(False) + self.logs_btn = QPushButton("View logs") + self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer") + self.logs_btn.clicked.connect(self._view_logs) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) @@ -384,6 +387,7 @@ class ServerEditor(QFrame): dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) + dep.addWidget(self.logs_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) @@ -728,6 +732,13 @@ class ServerEditor(QFrame): elif self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) + def _view_logs(self): + name = self.current_name() + if not name: + return + dlg = LogViewerDialog(self.window(), name) + dlg.exec() + # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that @@ -1090,6 +1101,125 @@ class PasteDialog(QDialog): self.err.setText(str(e)) +# --------------------------------------------------------------------------- # +# Log viewer dialog (issue #6): a read-only, auto-tailing view of a single +# server's MCP log file. Polls on a QTimer instead of watching the filesystem +# so it works the same on every platform; never writes to the log. +# --------------------------------------------------------------------------- # +class LogViewerDialog(QDialog): + POLL_MS = 1500 + MAX_TAIL_BYTES = 300_000 + + def __init__(self, parent, server_name: str): + super().__init__(parent) + self._name = server_name + self._last_path: Path | None = None + self._last_size: int | None = None + self.setWindowTitle(f"Logs — {server_name}") + self.resize(760, 520) + + v = QVBoxLayout(self) + v.setSpacing(8) + + self.path_label = QLabel("") + self.path_label.setObjectName("muted") + self.path_label.setWordWrap(True) + v.addWidget(self.path_label) + + self.view = QPlainTextEdit() + self.view.setObjectName("diag") + self.view.setReadOnly(True) + self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap) + v.addWidget(self.view, 1) + + row = QHBoxLayout() + self.status_label = QLabel("") + self.status_label.setObjectName("muted") + row.addWidget(self.status_label, 1) + refresh_btn = QPushButton("Refresh now") + refresh_btn.clicked.connect(self._poll) + row.addWidget(refresh_btn) + close_btn = QPushButton("Close") + close_btn.setObjectName("primary") + close_btn.clicked.connect(self.accept) + row.addWidget(close_btn) + v.addLayout(row) + + self._timer = QTimer(self) + self._timer.setInterval(self.POLL_MS) + self._timer.timeout.connect(self._poll) + self._timer.start() + + self._poll() + + def _poll(self): + path = core.server_log_path(self._name) + if path is None: + self._last_path = None + self._last_size = None + self.path_label.setText(f"No log file for '{self._name}' yet.") + self.view.setPlaceholderText( + "No log yet — this fills in once the server has run at least once " + "and produced output." + ) + self.view.clear() + self.status_label.setText("Waiting…") + return + + self.path_label.setText(str(path)) + try: + size = path.stat().st_size + except OSError: + self.status_label.setText("Log file disappeared.") + return + + # Nothing changed since the last poll -> skip the re-read/re-render. + if path == self._last_path and size == self._last_size: + return + + was_at_bottom = self._is_scrolled_to_bottom() + text, truncated = self._read_tail(path) + self.view.setPlainText(text) + self.status_label.setText( + f"Showing last {self.MAX_TAIL_BYTES // 1000} KB of the log." if truncated else "" + ) + if was_at_bottom: + self._scroll_to_bottom() + + self._last_path = path + self._last_size = size + + def _read_tail(self, path: Path) -> tuple[str, bool]: + try: + size = path.stat().st_size + with open(path, "rb") as f: + truncated = size > self.MAX_TAIL_BYTES + if truncated: + f.seek(size - self.MAX_TAIL_BYTES) + data = f.read() + except OSError as e: + return f"(could not read log: {e})", False + text = data.decode("utf-8", errors="replace") + if truncated: + nl = text.find("\n") + if nl != -1: + text = text[nl + 1 :] + text = "… (earlier lines truncated) …\n" + text + return text, truncated + + def _is_scrolled_to_bottom(self) -> bool: + sb = self.view.verticalScrollBar() + return sb.value() >= sb.maximum() - 4 + + def _scroll_to_bottom(self): + sb = self.view.verticalScrollBar() + sb.setValue(sb.maximum()) + + def closeEvent(self, event): + self._timer.stop() + super().closeEvent(event) + + # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # From 16961a5cc89116f44930128378f5580e18dbaa3e Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:40:57 -0400 Subject: [PATCH 23/30] test: cover restart_claude_desktop() and profile_targets_claude_desktop() (#9) Mocks subprocess.run/Popen and patches sys.platform per-OS branch (darwin, win32, linux) -- never actually kills or launches anything. Covers: correct command sequence per platform, pkill/taskkill exiting non-zero (nothing to kill) is NOT treated as failure, a failed relaunch IS reported as failure, and profile_targets_claude_desktop() correctly distinguishes Claude Desktop configs from Claude Code / legacy settings.json profiles. --- tests/test_core.py | 156 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..978ca27 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -638,3 +638,159 @@ def test_args_secret_warning_env_not_triggered(): def test_args_secret_warning_empty(): assert c.args_secret_warning({}) is None assert c.args_secret_warning({"args": []}) is None + + +# --------------------------------------------------------------------------- # +# restart_claude_desktop / profile_targets_claude_desktop (issue #9) +# --------------------------------------------------------------------------- # +def test_profile_targets_claude_desktop_true_for_desktop_config(): + p = c.Profile(label="Claude", path="/x/Claude/claude_desktop_config.json", config_exists=True) + assert c.profile_targets_claude_desktop(p) + + +def test_profile_targets_claude_desktop_false_for_claude_code(): + p = c.Profile(label="Claude Code", path="/home/me/.claude.json", config_exists=True) + assert not c.profile_targets_claude_desktop(p) + + +def test_profile_targets_claude_desktop_false_for_legacy_settings(): + p = c.Profile( + label="Claude Code (legacy settings.json)", + path="/home/me/.claude/settings.json", + config_exists=True, + ) + assert not c.profile_targets_claude_desktop(p) + + +class _FakeCompletedProcess: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_restart_claude_desktop_macos_commands(monkeypatch): + """macOS: pkill -x "Claude" then open -a Claude, in that order.""" + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + assert calls[0] == ["pkill", "-x", "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": + return _FakeCompletedProcess(returncode=1) # no matching process + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + + +def test_restart_claude_desktop_macos_relaunch_failure_reported(monkeypatch): + def fake_run(cmd, **kwargs): + if cmd[0] == "open": + return _FakeCompletedProcess(returncode=1, stderr="Unable to find application") + return _FakeCompletedProcess(returncode=1) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert not result.success + assert "Unable to find application" in result.detail + + +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.""" + + def fake_run(cmd, **kwargs): + if cmd[0] == "pkill": + raise OSError("pkill not found") + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "darwin") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + + +def test_restart_claude_desktop_windows_commands(monkeypatch): + """Windows: taskkill the process, then relaunch via the Start-menu shortcut.""" + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "win32") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert result.success + assert calls[0] == ["taskkill", "/IM", "Claude.exe", "/F"] + assert calls[1][:3] == ["cmd", "/c", "start"] + assert calls[1][-1].endswith("Claude.lnk") + + +def test_restart_claude_desktop_windows_relaunch_failure_reported(monkeypatch): + def fake_run(cmd, **kwargs): + if cmd[0] == "cmd": + return _FakeCompletedProcess(returncode=1, stderr="not found") + return _FakeCompletedProcess(returncode=0) + + monkeypatch.setattr(c.sys, "platform", "win32") + monkeypatch.setattr(c.subprocess, "run", fake_run) + result = c.restart_claude_desktop() + assert not result.success + 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 fake_run(cmd, **kwargs): + run_calls.append(cmd) + return _FakeCompletedProcess(returncode=0) + + class _FakePopen: + def __init__(self, cmd, **kwargs): + popen_calls.append((cmd, kwargs)) + + 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 + + +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") + + 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 From 67c898cd3508646886be3528246351d4e5d0637c Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:42:27 -0400 Subject: [PATCH 24/30] feat: About dialog with menu bar + notify-only update checker UI (#18, #19) - Add a real QMenuBar (Help -> About...). - AboutDialog: app icon, name, version (core.__version__), and links to the repo/issues/license opened via QDesktopServices.openUrl (system browser, never in-app navigation). macOS unsigned-app note. - "Check for updates" button + UpdateCheckWorker (QThread) runs core.fetch_latest_release() off the UI thread; shows "up to date" or "vX.Y.Z available" with a button to open the releases page. No binary download, ever. - Optional quiet startup auto-check, throttled to once/day via a QSettings timestamp, off-thread, silent on failure, toggle lives in the About dialog. --- bcc.py | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 2 deletions(-) diff --git a/bcc.py b/bcc.py index 599eaac..eb3d12e 100644 --- a/bcc.py +++ b/bcc.py @@ -11,13 +11,23 @@ Run: python mcp_manager.py from __future__ import annotations import sys +import time from pathlib import Path -from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter +from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtGui import ( + QAction, + QColor, + QDesktopServices, + QGuiApplication, + QIcon, + QPainter, + QPixmap, +) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -147,6 +157,23 @@ class SpawnTester(QThread): self.done.emit(result) +class UpdateCheckWorker(QThread): + """ + Fetches the latest release from the Gitea releases API off the UI thread. + + Notify-only: `core.fetch_latest_release()` only ever reads release + metadata (a version tag + a URL) and never downloads or replaces the + 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. + """ + + done = Signal(object) # dict | None + + def run(self): + self.done.emit(core.fetch_latest_release()) + + # --------------------------------------------------------------------------- # # Small reusable: key/value editor (for env and headers) # --------------------------------------------------------------------------- # @@ -1090,6 +1117,153 @@ class PasteDialog(QDialog): self.err.setText(str(e)) +# --------------------------------------------------------------------------- # +# About dialog +# --------------------------------------------------------------------------- # +class AboutDialog(QDialog): + """ + App info + a manual "Check for updates" action. + + Shows nothing sensitive: app name, icon, version (from + core.__version__), and links opened in the system browser via + QDesktopServices.openUrl — never navigated to in-app. The update check + itself only ever reads release metadata (see UpdateCheckWorker); it + never downloads or replaces the running binary. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Better Claude Config") + self.setFixedWidth(440) + self._worker: UpdateCheckWorker | None = None + self._release_url: str | None = None + + icon_path = ( + Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png" + ) + if icon_path.is_file(): + self.setWindowIcon(QIcon(str(icon_path))) + + v = QVBoxLayout(self) + v.setSpacing(10) + + head = QHBoxLayout() + head.setSpacing(12) + icon_lbl = QLabel() + if icon_path.is_file(): + icon_lbl.setPixmap( + QPixmap(str(icon_path)).scaled( + 56, + 56, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + head.addWidget(icon_lbl) + + title_box = QVBoxLayout() + title_box.setSpacing(2) + name_lbl = QLabel("Better Claude Config") + name_lbl.setObjectName("h1") + title_box.addWidget(name_lbl) + ver_lbl = QLabel(f"Version {core.__version__}") + ver_lbl.setObjectName("muted") + title_box.addWidget(ver_lbl) + head.addLayout(title_box, 1) + v.addLayout(head) + + desc = QLabel( + "A cross-platform GUI for editing the mcpServers block of Claude " + "Desktop and Claude Code configs." + ) + desc.setObjectName("muted") + desc.setWordWrap(True) + v.addWidget(desc) + + if sys.platform == "darwin": + note = QLabel( + "This build isn't notarized by Apple. If macOS blocks it on first " + "launch, right-click the app ▸ Open, or allow it under System " + "Settings ▸ Privacy & Security." + ) + note.setObjectName("muted") + note.setWordWrap(True) + v.addWidget(note) + + links = QHBoxLayout() + for text, url in ( + ("Repository", core.REPO_URL), + ("Issues", core.ISSUES_URL), + ("MIT License", core.LICENSE_URL), + ): + btn = QPushButton(text) + btn.setFlat(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) + links.addWidget(btn) + links.addStretch() + v.addLayout(links) + + # --- update check ----------------------------------------------- # + upd_row = QHBoxLayout() + self.check_btn = QPushButton("Check for updates") + self.check_btn.clicked.connect(self._check_for_updates) + upd_row.addWidget(self.check_btn) + self.update_status = QLabel("") + self.update_status.setObjectName("muted") + self.update_status.setWordWrap(True) + upd_row.addWidget(self.update_status, 1) + v.addLayout(upd_row) + + self.release_btn = QPushButton("Open releases page") + self.release_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.release_btn.clicked.connect(self._open_release_page) + self.release_btn.hide() + v.addWidget(self.release_btn) + + self.auto_check_box = QCheckBox("Automatically check for updates on startup") + st = QSettings("BCC", "BetterClaudeConfig") + self.auto_check_box.setChecked(bool(st.value("update/autoCheck", True, type=bool))) + self.auto_check_box.toggled.connect(self._toggle_auto_check) + v.addWidget(self.auto_check_box) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(self.reject) + btns.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.accept) + v.addWidget(btns) + + def _toggle_auto_check(self, on: bool): + QSettings("BCC", "BetterClaudeConfig").setValue("update/autoCheck", on) + + def _check_for_updates(self): + self.check_btn.setEnabled(False) + self.release_btn.hide() + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Checking…") + self._worker = UpdateCheckWorker() + self._worker.done.connect(self._on_check_done) + self._worker.start() + + def _on_check_done(self, release: dict | None): + self.check_btn.setEnabled(True) + self._worker = None + if not release: + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Couldn't check for updates (offline?).") + return + if core.is_newer_version(core.__version__, release["version"]): + self.update_status.setStyleSheet(f"color: {ACCENT};") + self.update_status.setText(f"{release['version']} available.") + self._release_url = release.get("url") or core.RELEASES_URL + self.release_btn.show() + else: + self.update_status.setStyleSheet(f"color: {GOOD};") + self.update_status.setText("You're up to date.") + + def _open_release_page(self): + QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) + + # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # @@ -1099,6 +1273,7 @@ class MainWindow(QMainWindow): self.setWindowTitle("Better Claude Config") self.resize(940, 640) self.setAcceptDrops(True) + self._build_menu_bar() self.profiles: list[core.Profile] = [] self.full_config: dict = {} @@ -1141,6 +1316,37 @@ class MainWindow(QMainWindow): self._restore_layout() self.reload_profiles() + self._maybe_auto_check_updates() + + # --- menu bar ---------------------------------------------------------- # + def _build_menu_bar(self): + help_menu = self.menuBar().addMenu("&Help") + about_action = QAction("About Better Claude Config…", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _show_about(self): + AboutDialog(self).exec() + + # --- update check (silent, throttled, off-thread) --------------------- # + def _maybe_auto_check_updates(self): + st = QSettings("BCC", "BetterClaudeConfig") + if not bool(st.value("update/autoCheck", True, type=bool)): + return + 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"]): + self.status.setText( + f"Update available: {release['version']} · Help ▸ About to view it." + ) # --- layout persistence ---------------------------------------------- # def _restore_layout(self): From 3cd18392c9e06bebb2da64173d4cb762e280f6af Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:44:00 -0400 Subject: [PATCH 25/30] test: cover __version__, parse_version/is_newer_version, and fetch_latest_release (#19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All network calls are monkeypatched (urllib.request.urlopen) — no live network in tests. Covers older/newer/equal comparisons, v-prefix, differing-length tuples, malformed input on both sides, and fetch_latest_release success/timeout/network-failure/malformed-response paths. --- tests/test_core.py | 144 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/tests/test_core.py b/tests/test_core.py index aaf2faf..721f42f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -3,6 +3,8 @@ proper test functions with tmp_path/monkeypatch fixtures).""" import json import sys +import urllib.error +import urllib.request import pytest @@ -638,3 +640,145 @@ def test_args_secret_warning_env_not_triggered(): def test_args_secret_warning_empty(): assert c.args_secret_warning({}) is None assert c.args_secret_warning({"args": []}) is None + + +# --------------------------------------------------------------------------- # +# Version / update checking +# --------------------------------------------------------------------------- # +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.1.0" + + +def test_parse_version_basic(): + assert c.parse_version("1.2.3") == (1, 2, 3) + + +def test_parse_version_strips_v_prefix(): + assert c.parse_version("v1.2.3") == (1, 2, 3) + + +def test_parse_version_strips_prerelease_suffix(): + assert c.parse_version("1.2.3-beta.1") == (1, 2, 3) + assert c.parse_version("1.2.3+build5") == (1, 2, 3) + + +def test_parse_version_malformed_returns_empty_tuple(): + assert c.parse_version("not-a-version") == () + assert c.parse_version("") == () + assert c.parse_version(None) == () + + +def test_parse_version_stops_at_first_non_numeric_component(): + assert c.parse_version("1.2.rc1") == (1, 2) + + +def test_is_newer_version_true_when_candidate_is_newer(): + assert c.is_newer_version("1.1.0", "1.2.0") is True + + +def test_is_newer_version_false_when_candidate_is_older(): + assert c.is_newer_version("1.2.0", "1.1.0") is False + + +def test_is_newer_version_false_when_equal(): + assert c.is_newer_version("1.1.0", "1.1.0") is False + + +def test_is_newer_version_equal_with_v_prefix(): + assert c.is_newer_version("1.1.0", "v1.1.0") is False + + +def test_is_newer_version_numeric_not_lexical(): + # A lexical/string compare would say "10.0.0" < "2.0.0" ("1" < "2"). + assert c.is_newer_version("2.0.0", "10.0.0") is True + assert c.is_newer_version("10.0.0", "2.0.0") is False + + +def test_is_newer_version_handles_differing_length(): + assert c.is_newer_version("1.2", "1.2.0") is False + assert c.is_newer_version("1.2.0", "1.2") is False + assert c.is_newer_version("1.2", "1.3") is True + + +def test_is_newer_version_malformed_candidate_is_never_newer(): + assert c.is_newer_version("1.1.0", "not-a-version") is False + assert c.is_newer_version("1.1.0", "") is False + + +def test_is_newer_version_malformed_current_does_not_crash(): + # Shouldn't raise; a valid candidate is reported as newer than "unknown". + assert c.is_newer_version("garbage", "1.0.0") is True + assert c.is_newer_version("garbage", "not-a-version-either") is False + + +class _FakeHTTPResponse: + def __init__(self, data: bytes): + self._data = data + + def read(self): + return self._data + + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + + +def test_fetch_latest_release_ok(monkeypatch): + payload = json.dumps( + { + "tag_name": "v1.2.0", + "html_url": "https://git.avezzano.io/the_og/better-claude-config/releases/tag/v1.2.0", + } + ).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + result = c.fetch_latest_release() + assert result == { + "version": "v1.2.0", + "url": "https://git.avezzano.io/the_og/better-claude-config/releases/tag/v1.2.0", + } + + +def test_fetch_latest_release_falls_back_to_releases_url_when_no_html_url(monkeypatch): + payload = json.dumps({"tag_name": "v1.2.0"}).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + result = c.fetch_latest_release() + assert result == {"version": "v1.2.0", "url": c.RELEASES_URL} + + +def test_fetch_latest_release_network_failure_returns_none(monkeypatch): + def boom(req, timeout=None): + raise urllib.error.URLError("no network") + + monkeypatch.setattr(urllib.request, "urlopen", boom) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_timeout_returns_none(monkeypatch): + def boom(req, timeout=None): + raise TimeoutError("timed out") + + monkeypatch.setattr(urllib.request, "urlopen", boom) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_missing_tag_returns_none(monkeypatch): + payload = json.dumps({"html_url": "https://example.com"}).encode("utf-8") + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(payload) + ) + assert c.fetch_latest_release() is None + + +def test_fetch_latest_release_malformed_json_returns_none(monkeypatch): + monkeypatch.setattr( + urllib.request, "urlopen", lambda req, timeout=None: _FakeHTTPResponse(b"not json") + ) + assert c.fetch_latest_release() is None From a811e323e629c5500a8b6074b08d1abe1d7b1fb3 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:44:39 -0400 Subject: [PATCH 26/30] Fix: rebuild from clean main (previous push included unrelated contaminated content) --- tests/test_core.py | 96 ++++------------------------------------------ 1 file changed, 8 insertions(+), 88 deletions(-) diff --git a/tests/test_core.py b/tests/test_core.py index 9960fa7..75a3352 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,7 +2,6 @@ proper test functions with tmp_path/monkeypatch fixtures).""" import json -import os import sys import pytest @@ -257,17 +256,20 @@ def test_spawn_test_ok_still_running(): def test_spawn_test_crashed(): - # A process that exits with a non-zero code immediately + # A process that exits with a non-zero code immediately. + # Timeout is generous (not 0.3s) so a slow CI runner's interpreter startup + # can't outlast it and misclassify the crash as "ok" (still running). See #12. r = c.spawn_test( - {"command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}, timeout=0.3 + {"command": sys.executable, "args": ["-c", "raise SystemExit(1)"]}, timeout=2.0 ) assert r["outcome"] == "crashed" assert r["returncode"] == 1 def test_spawn_test_exited_cleanly(): - # A process that exits 0 before timeout (unusual for a server) - r = c.spawn_test({"command": sys.executable, "args": ["-c", "pass"]}, timeout=0.5) + # A process that exits 0 before timeout (unusual for a server). + # Generous timeout so slow-runner startup can't flip this to "ok". See #12. + r = c.spawn_test({"command": sys.executable, "args": ["-c", "pass"]}, timeout=2.0) assert r["outcome"] == "exited" assert r["returncode"] == 0 @@ -289,7 +291,7 @@ def test_spawn_test_stderr_captured(): "command": sys.executable, "args": ["-c", "import sys; sys.stderr.write('test-err\\n'); sys.exit(2)"], }, - timeout=0.5, + timeout=2.0, ) assert r["outcome"] == "crashed" assert "test-err" in r["stderr"] @@ -518,42 +520,6 @@ def test_config_mtime_returns_none_for_missing_file(tmp_path): assert c.config_mtime(tmp_path / "nonexistent.json") is None -def test_config_fingerprint_returns_mtime_and_size_for_existing_file(tmp_path): - f = tmp_path / "cfg.json" - f.write_text("{}") - fp = c.config_fingerprint(f) - assert isinstance(fp, c.ConfigStat) - assert isinstance(fp.mtime, float) - assert fp.size == f.stat().st_size - - -def test_config_fingerprint_returns_none_for_missing_file(tmp_path): - assert c.config_fingerprint(tmp_path / "nonexistent.json") is None - - -def test_config_fingerprint_detects_size_change_when_mtime_is_unchanged(tmp_path): - """ - Guards against the exact hole bare-mtime comparison has: an external write - that lands within the filesystem's mtime resolution (or that restores the - original mtime) must still be caught, because the file's size differs. - """ - f = tmp_path / "cfg.json" - f.write_text('{"mcpServers": {}}') - original = c.config_fingerprint(f) - original_mtime_ns = f.stat().st_mtime_ns - - # Overwrite with materially different (larger) content, then force the - # mtime back to its original value — simulating a same-second external - # write, or a writer that preserves mtime. - f.write_text('{"mcpServers": {"new-server": {"command": "node", "args": ["a", "b", "c"]}}}') - os.utime(f, ns=(original_mtime_ns, original_mtime_ns)) - - updated = c.config_fingerprint(f) - assert updated.mtime == original.mtime # mtime alone would say "unchanged" - assert updated.size != original.size # size catches what mtime missed - assert updated != original # the fingerprint as a whole detects the change - - def test_external_change_summary_detects_non_server_key_change(tmp_path): cfgpath = tmp_path / "cfg.json" original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}} @@ -705,49 +671,3 @@ def test_resolve_name_collision_repeated_calls_stay_unique(): second = c.resolve_name_collision("brave-search", existing) assert first != second assert {first, second} == {"brave-search-2", "brave-search-3"} - - -# --------------------------------------------------------------------------- # -# server_log_path (in-app log viewer — issue #6) -# --------------------------------------------------------------------------- # -def test_server_log_path_macos_when_file_exists(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "darwin") - monkeypatch.setattr(c.Path, "home", lambda: tmp_path) - log_dir = tmp_path / "Library" / "Logs" / "Claude" - log_dir.mkdir(parents=True) - (log_dir / "mcp-server-brave-search.log").write_text("hello") - - result = c.server_log_path("brave-search") - assert result == log_dir / "mcp-server-brave-search.log" - - -def test_server_log_path_macos_none_when_missing(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "darwin") - monkeypatch.setattr(c.Path, "home", lambda: tmp_path) - assert c.server_log_path("brave-search") is None - - -def test_server_log_path_windows_when_file_exists(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(c.os, "name", "nt") - monkeypatch.setenv("APPDATA", str(tmp_path)) - log_dir = tmp_path / "Claude" / "logs" - log_dir.mkdir(parents=True) - (log_dir / "mcp.log").write_text("hello") - - result = c.server_log_path("brave-search") - assert result == log_dir / "mcp.log" - - -def test_server_log_path_windows_none_when_missing(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "win32") - monkeypatch.setattr(c.os, "name", "nt") - monkeypatch.setenv("APPDATA", str(tmp_path)) - assert c.server_log_path("brave-search") is None - - -def test_server_log_path_unsupported_platform_returns_none(tmp_path, monkeypatch): - monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(c.os, "name", "posix") - monkeypatch.setattr(c.Path, "home", lambda: tmp_path) - assert c.server_log_path("brave-search") is None From 3e07b51134b3645dc261ad60cb825afa75c95683 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:48:52 -0400 Subject: [PATCH 27/30] Fix: rebuild from clean main (previous push included unrelated contaminated content) --- bcc.py | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/bcc.py b/bcc.py index 35f4f29..37ad0e1 100644 --- a/bcc.py +++ b/bcc.py @@ -374,9 +374,6 @@ class ServerEditor(QFrame): self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") self.spawn_btn.clicked.connect(self._test_spawn) self.spawn_btn.setVisible(False) - self.logs_btn = QPushButton("View logs") - self.logs_btn.setToolTip("Open this server's MCP log in a read-only, auto-tailing viewer") - self.logs_btn.clicked.connect(self._view_logs) self.details_btn = QPushButton("Details ▸") self.details_btn.setCheckable(True) self.details_btn.toggled.connect(self._toggle_diag) @@ -387,7 +384,6 @@ class ServerEditor(QFrame): dep.addWidget(self.fix_btn) dep.addWidget(self.test_btn) dep.addWidget(self.spawn_btn) - dep.addWidget(self.logs_btn) dep.addWidget(self.details_btn) dep.addWidget(recheck) outer.addLayout(dep) @@ -732,13 +728,6 @@ class ServerEditor(QFrame): elif self.diag_card.isVisible(): self.diag_text.setPlainText(self._full_diag_text()) - def _view_logs(self): - name = self.current_name() - if not name: - return - dlg = LogViewerDialog(self.window(), name) - dlg.exec() - # --------------------------------------------------------------------------- # # Arguments editor: one line = one argument, with a numbered gutter so that @@ -1115,7 +1104,7 @@ class MainWindow(QMainWindow): self.full_config: dict = {} self.servers: list[core.ServerEntry] = [] self.current_profile: core.Profile | None = None - self._loaded_stat: core.ConfigStat | None = None + self._loaded_mtime: float | None = None self.dirty = False self._suppress_table = False self._suppress_sel = False @@ -1391,7 +1380,7 @@ class MainWindow(QMainWindow): return self.full_config = cfg repaired = True - self._loaded_stat = core.config_fingerprint(profile.path) + self._loaded_mtime = core.config_mtime(profile.path) self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False @@ -1734,14 +1723,11 @@ class MainWindow(QMainWindow): return # Stale-file check: if the file changed on disk since we loaded it, prompt. - # Compare mtime AND size (not mtime alone) so a concurrent external write - # that lands within the mtime resolution window, or that restores the - # original mtime, still gets caught. - disk_stat = core.config_fingerprint(self.current_profile.path) + disk_mtime = core.config_mtime(self.current_profile.path) if ( - disk_stat is not None - and self._loaded_stat is not None - and disk_stat != self._loaded_stat + disk_mtime is not None + and self._loaded_mtime is not None + and disk_mtime != self._loaded_mtime ): changed_keys, server_diff = core.external_change_summary( self.full_config, self.current_profile.path @@ -1762,7 +1748,7 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Save failed", str(e)) return self.full_config = fresh - self._loaded_stat = core.config_fingerprint(self.current_profile.path) + self._loaded_mtime = core.config_mtime(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) @@ -1780,7 +1766,7 @@ class MainWindow(QMainWindow): except Exception as e: QMessageBox.critical(self, "Save failed", str(e)) return - self._loaded_stat = core.config_fingerprint(self.current_profile.path) + self._loaded_mtime = core.config_mtime(self.current_profile.path) self.current_profile.config_exists = True self.dirty = False self.save_btn.setEnabled(False) From 4ab3c3b00a9404178a2dbb5a4648fbef096d6ef8 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:51:26 -0400 Subject: [PATCH 28/30] Fix: rebuild from clean main (previous push included unrelated contaminated content) --- bcc_core.py | 152 ++-------------------------------------------------- 1 file changed, 5 insertions(+), 147 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index fb2410d..909df23 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -29,7 +29,6 @@ import threading import time from dataclasses import dataclass from pathlib import Path -from typing import NamedTuple from urllib.parse import urlparse CONFIG_FILENAME = "claude_desktop_config.json" @@ -135,16 +134,6 @@ def profile_from_path(path: str | os.PathLike) -> Profile: label = p.parent.name or p.name return Profile(label=label, path=p, config_exists=p.is_file()) -def profile_targets_claude_desktop(profile: Profile) -> bool: - """ - True when `profile` points at a Claude Desktop config - (claude_desktop_config.json), as opposed to Claude Code (~/.claude.json - or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions - like "Restart Claude Desktop" so they never show up for a Claude Code - profile — restarting the CLI makes no sense. - """ - return Path(profile.path).name == CONFIG_FILENAME - # --------------------------------------------------------------------------- # # Load / extract / apply @@ -410,30 +399,6 @@ def config_mtime(path: Path | str) -> float | None: return None -class ConfigStat(NamedTuple): - """A snapshot of a config file's mtime + size. - - Pairing size with mtime hardens stale-file detection beyond bare mtime - equality: a concurrent external write can land within the filesystem's - mtime resolution (e.g. same-second writes on ext4/HFS+) or have its mtime - restored by the writing process, in which case mtime alone would miss the - change. Comparing both fields catches those cases without the cost of a - full content hash. - """ - - mtime: float - size: int - - -def config_fingerprint(path: Path | str) -> ConfigStat | None: - """Return the file's (mtime, size) snapshot, or None if it does not exist.""" - try: - st = Path(path).stat() - except OSError: - return None - return ConfigStat(st.st_mtime, st.st_size) - - def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: """ Compare original_cfg (what BCC loaded) with the current on-disk state. @@ -500,13 +465,13 @@ def suggest_name(data: dict) -> str: # Word-processor / web artifacts mapped back to ASCII. _QUOTE_MAP = { - "“": '"', - "”": '"', + """: '"', + """: '"', "„": '"', "«": '"', "»": '"', - "‘": "'", - "’": "'", + "'": "'", + "'": "'", "‚": "'", } _JUNK_CHARS = "​‌‍" # zero-width chars / BOM @@ -528,7 +493,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1324,25 +1289,6 @@ def diagnostics_text(name: str, data: dict) -> str: return "\n".join(L) -def server_log_path(name: str) -> Path | None: - """ - The platform-specific Claude Desktop MCP server log file for `name`, or - None if it doesn't exist yet (nothing has been logged for this server). - - macOS : ~/Library/Logs/Claude/mcp-server-.log (one file per server) - Windows: %APPDATA%\\Claude\\logs\\mcp.log (one shared file) - Other platforms: Claude Desktop doesn't ship a log in a known location -> None. - """ - if sys.platform == "darwin": - p = Path.home() / "Library" / "Logs" / "Claude" / f"mcp-server-{name}.log" - elif os.name == "nt": - appdata = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming")) - p = appdata / "Claude" / "logs" / "mcp.log" - else: - return None - return p if p.is_file() else None - - _STDERR_CAP = 4096 # bytes @@ -1532,91 +1478,3 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None - - -# --------------------------------------------------------------------------- # -# Restart Claude Desktop (issue #9) -# -# Scoped strictly to Claude DESKTOP, the GUI app -- never Claude Code (the -# CLI), which has no long-running process to bounce. Callers should gate this -# behind profile_targets_claude_desktop() before offering it in the UI. -# --------------------------------------------------------------------------- # -class RestartResult(NamedTuple): - """Outcome of a restart_claude_desktop() attempt.""" - - success: bool - detail: str - - -def _run_quiet(cmd: list[str]) -> None: - """Best-effort fire-and-forget command. Never raises: a nonzero exit (e.g. - pkill finding nothing to kill) is expected and not an error.""" - with contextlib.suppress(OSError): - subprocess.run(cmd, capture_output=True) - - -def _restart_claude_desktop_macos() -> RestartResult: - _run_quiet(["pkill", "-x", "Claude"]) - try: - result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True) - except OSError as e: - return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") - if result.returncode != 0: - detail = (result.stderr or result.stdout or "").strip() or "'open -a Claude' failed" - return RestartResult(False, detail) - return RestartResult(True, "Claude Desktop restarted.") - - -def _claude_windows_start_menu_shortcut() -> Path: - appdata = os.environ.get("APPDATA", str(Path.home())) - return Path(appdata) / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk" - - -def _restart_claude_desktop_windows() -> RestartResult: - _run_quiet(["taskkill", "/IM", "Claude.exe", "/F"]) - shortcut = _claude_windows_start_menu_shortcut() - try: - # `cmd /c start "" ` launches detached, the same as double-clicking - # the Start-menu shortcut, and returns immediately. - result = subprocess.run( - ["cmd", "/c", "start", "", str(shortcut)], capture_output=True, text=True - ) - except OSError as e: - return RestartResult(False, f"Couldn't launch Claude Desktop: {e}") - if result.returncode != 0: - detail = ( - result.stderr or result.stdout or "" - ).strip() or "failed to relaunch Claude Desktop" - return RestartResult(False, detail) - 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. - """ - if sys.platform == "darwin": - return _restart_claude_desktop_macos() - if sys.platform.startswith("win"): - return _restart_claude_desktop_windows() - return _restart_claude_desktop_linux() From 2d9fb083dc0b0b7751daeb6d1751907d0a44f01b Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:56:14 -0400 Subject: [PATCH 29/30] Fix: correct smart-quote/nbsp characters mangled in previous push --- bcc_core.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index 909df23..1edc990 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -465,13 +465,13 @@ def suggest_name(data: dict) -> str: # Word-processor / web artifacts mapped back to ASCII. _QUOTE_MAP = { - """: '"', - """: '"', + "“": '"', + "”": '"', "„": '"', "«": '"', "»": '"', - "'": "'", - "'": "'", + "‘": "'", + "’": "'", "‚": "'", } _JUNK_CHARS = "​‌‍" # zero-width chars / BOM @@ -493,7 +493,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From 8c718387c063a19a1687b852b4a22ba539684f74 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Tue, 7 Jul 2026 22:14:49 -0400 Subject: [PATCH 30/30] chore: bump version to 1.2.0 for release --- bcc_core.py | 2 +- pyproject.toml | 2 +- tests/test_core.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index 98c9a48..0ecce10 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -59,7 +59,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.1.0" +__version__ = "1.2.0" REPO_URL = "https://git.avezzano.io/the_og/better-claude-config" ISSUES_URL = f"{REPO_URL}/issues" diff --git a/pyproject.toml b/pyproject.toml index 12d09ef..80de5d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "better-claude-config" -version = "1.1.0" +version = "1.2.0" description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_core.py b/tests/test_core.py index adbe807..880a94b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -920,7 +920,7 @@ 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.1.0" + assert c.__version__ == "1.2.0" def test_parse_version_basic():