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: -- 2.52.0 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() -- 2.52.0 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: -- 2.52.0 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: -- 2.52.0 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 -- 2.52.0 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: -- 2.52.0 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 -- 2.52.0 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. -- 2.52.0 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() -- 2.52.0 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 -- 2.52.0 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 -- 2.52.0 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 -- 2.52.0 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 -- 2.52.0 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: -- 2.52.0 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) -- 2.52.0 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 + -- 2.52.0 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) -- 2.52.0 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: -- 2.52.0 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"}}} -- 2.52.0 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 -- 2.52.0 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 - -- 2.52.0 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 # --------------------------------------------------------------------------- # -- 2.52.0 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 -- 2.52.0 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): -- 2.52.0 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 -- 2.52.0 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 -- 2.52.0 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) -- 2.52.0 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() -- 2.52.0 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: -- 2.52.0 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(): -- 2.52.0