From fe66d53e9f7377a5be6c59b776ba9acd67a1b903 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:25:33 -0400 Subject: [PATCH 1/6] feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `__version__ = "1.1.0"` as the single source of truth (matches pyproject.toml). - Add parse_version()/is_newer_version() for numeric (never lexical) version comparison, handling v-prefix, pre-release suffixes, and malformed input. - Add fetch_latest_release(): reads the public Gitea releases API (anonymous, no token) and returns {version, url} or None on any failure. Never downloads or touches a binary — metadata only. --- bcc_core.py | 108 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index dc41ba3..1b33a66 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -46,6 +46,112 @@ MAX_BACKUPS = 15 KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} +# --------------------------------------------------------------------------- # +# Version / update checking +# +# __version__ is the single source of truth for the app version (must match +# pyproject.toml's [project] version). The About dialog and the update +# checker both read this constant instead of hard-coding a version string. +# +# The update checker is notify-only: it reads release metadata from the +# repo's Gitea releases API and NEVER downloads or replaces the running +# binary. All network I/O here is fail-quiet (returns None on any problem) +# so it's safe to run unattended, off the UI thread, at startup. +# --------------------------------------------------------------------------- # +__version__ = "1.1.0" + +REPO_URL = "https://git.avezzano.io/the_og/better-claude-config" +ISSUES_URL = f"{REPO_URL}/issues" +RELEASES_URL = f"{REPO_URL}/releases" +LICENSE_URL = f"{REPO_URL}/raw/branch/main/LICENSE" + +# Public repo -> anonymously reachable, no auth/token needed or embedded. +_RELEASES_API_URL = ( + "https://git.avezzano.io/api/v1/repos/the_og/better-claude-config/releases/latest" +) + + +def parse_version(v: str) -> tuple[int, ...]: + """ + Parse a version string into a tuple of ints for numeric comparison. + + Strips a leading 'v' ("v1.2.3" -> "1.2.3") and any pre-release/build + metadata after a '-' or '+' ("1.2.3-beta.1" -> "1.2.3"). Stops at the + first non-numeric dotted component. Empty or entirely non-numeric input + returns an empty tuple rather than raising, so a malformed tag from a + flaky API response degrades gracefully instead of crashing the caller. + """ + s = (v or "").strip() + if s[:1].lower() == "v": + s = s[1:] + s = re.split(r"[-+]", s, maxsplit=1)[0] + parts: list[int] = [] + for chunk in s.split("."): + m = re.match(r"\d+", chunk) + if not m: + break + parts.append(int(m.group())) + return tuple(parts) + + +def is_newer_version(current: str, candidate: str) -> bool: + """ + True if `candidate` is a strictly newer version than `current`. + + Comparison is purely numeric (major.minor.patch, ...) — NEVER a lexical + string compare, so "v2.0.0" vs "v10.0.0" resolves correctly instead of + sorting "2" after "10". Tuples of differing length are zero-padded before + comparing, so "1.2" and "1.2.0" are correctly treated as equal. + + An unparseable `candidate` always yields False (nothing to report). An + unparseable `current` is treated as "0" for comparison purposes — a + malformed local version shouldn't silently suppress a real update. + """ + cur = parse_version(current) + new = parse_version(candidate) + if not new: + return False + width = max(len(cur), len(new), 1) + cur = cur + (0,) * (width - len(cur)) + new = new + (0,) * (width - len(new)) + return new > cur + + +def fetch_latest_release(timeout: float = 4.0) -> dict | None: + """ + Query the repo's (public, anonymous) Gitea releases API for the latest + release. Returns {"version": "", "url": ""} on + success, or None on ANY failure: network error, timeout, bad status, + malformed JSON, or a response missing tag_name. + + Fail-quiet by design — this is meant to be called off the UI thread + (see UpdateCheckWorker in bcc.py) for both the About dialog's "Check for + updates" button and an optional silent startup check. Never downloads or + touches any binary; this only ever reads release metadata. + """ + import urllib.error + import urllib.request + + req = urllib.request.Request( + _RELEASES_API_URL, + headers={ + "Accept": "application/json", + "User-Agent": f"BetterClaudeConfig/{__version__}", + }, + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + payload = json.loads(r.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, ValueError, OSError): + return None + if not isinstance(payload, dict): + return None + tag = payload.get("tag_name") + if not tag or not isinstance(tag, str): + return None + return {"version": tag, "url": payload.get("html_url") or RELEASES_URL} + + # --------------------------------------------------------------------------- # # Data model # --------------------------------------------------------------------------- # @@ -474,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace(" ", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: From cffdee8a40d0d5449c2dfdaeacda1df8f8c7d553 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:29:33 -0400 Subject: [PATCH 2/6] chore: restore trailing newline in bcc_core.py From 0ffc6a1fb647e9ec5a7179639e6e6d47bf33983f Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:32:36 -0400 Subject: [PATCH 3/6] chore: restore trailing newline in bcc_core.py (attempt 2) --- bcc_core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bcc_core.py b/bcc_core.py index 1b33a66..db21ff7 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1565,3 +1565,4 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None + From 256827eaf320dd53430cff37dcc8df7d05545d17 Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:38:35 -0400 Subject: [PATCH 4/6] fix: use \xa0 escape instead of a literal NBSP byte (transmission-safe, same behavior); drop stray trailing blank line --- bcc_core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bcc_core.py b/bcc_core.py index db21ff7..2cbe54c 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -580,7 +580,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str: out = text for junk in _JUNK_CHARS: out = out.replace(junk, "") - out = out.replace(" ", " ") # non-breaking space + out = out.replace("\xa0", " ") # non-breaking space for smart, ascii_q in _QUOTE_MAP.items(): out = out.replace(smart, ascii_q) if out != text: @@ -1565,4 +1565,3 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N out["args"] = args return out, f"'{c}' → {resolved}" return data, None - From 67c898cd3508646886be3528246351d4e5d0637c Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:42:27 -0400 Subject: [PATCH 5/6] feat: About dialog with menu bar + notify-only update checker UI (#18, #19) - Add a real QMenuBar (Help -> About...). - AboutDialog: app icon, name, version (core.__version__), and links to the repo/issues/license opened via QDesktopServices.openUrl (system browser, never in-app navigation). macOS unsigned-app note. - "Check for updates" button + UpdateCheckWorker (QThread) runs core.fetch_latest_release() off the UI thread; shows "up to date" or "vX.Y.Z available" with a button to open the releases page. No binary download, ever. - Optional quiet startup auto-check, throttled to once/day via a QSettings timestamp, off-thread, silent on failure, toggle lives in the About dialog. --- bcc.py | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 208 insertions(+), 2 deletions(-) diff --git a/bcc.py b/bcc.py index 599eaac..eb3d12e 100644 --- a/bcc.py +++ b/bcc.py @@ -11,13 +11,23 @@ Run: python mcp_manager.py from __future__ import annotations import sys +import time from pathlib import Path -from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal -from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter +from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtGui import ( + QAction, + QColor, + QDesktopServices, + QGuiApplication, + QIcon, + QPainter, + QPixmap, +) from PySide6.QtWidgets import ( QAbstractItemView, QApplication, + QCheckBox, QComboBox, QDialog, QDialogButtonBox, @@ -147,6 +157,23 @@ class SpawnTester(QThread): self.done.emit(result) +class UpdateCheckWorker(QThread): + """ + Fetches the latest release from the Gitea releases API off the UI thread. + + Notify-only: `core.fetch_latest_release()` only ever reads release + metadata (a version tag + a URL) and never downloads or replaces the + running binary. Fails quiet — emits None on any network problem — so + it's safe to fire unattended from a silent startup check as well as from + the About dialog's "Check for updates" button. + """ + + done = Signal(object) # dict | None + + def run(self): + self.done.emit(core.fetch_latest_release()) + + # --------------------------------------------------------------------------- # # Small reusable: key/value editor (for env and headers) # --------------------------------------------------------------------------- # @@ -1090,6 +1117,153 @@ class PasteDialog(QDialog): self.err.setText(str(e)) +# --------------------------------------------------------------------------- # +# About dialog +# --------------------------------------------------------------------------- # +class AboutDialog(QDialog): + """ + App info + a manual "Check for updates" action. + + Shows nothing sensitive: app name, icon, version (from + core.__version__), and links opened in the system browser via + QDesktopServices.openUrl — never navigated to in-app. The update check + itself only ever reads release metadata (see UpdateCheckWorker); it + never downloads or replaces the running binary. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Better Claude Config") + self.setFixedWidth(440) + self._worker: UpdateCheckWorker | None = None + self._release_url: str | None = None + + icon_path = ( + Path(__file__).resolve().parent / "icons" / "twin-gears" / "rounded" / "icon-128.png" + ) + if icon_path.is_file(): + self.setWindowIcon(QIcon(str(icon_path))) + + v = QVBoxLayout(self) + v.setSpacing(10) + + head = QHBoxLayout() + head.setSpacing(12) + icon_lbl = QLabel() + if icon_path.is_file(): + icon_lbl.setPixmap( + QPixmap(str(icon_path)).scaled( + 56, + 56, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation, + ) + ) + head.addWidget(icon_lbl) + + title_box = QVBoxLayout() + title_box.setSpacing(2) + name_lbl = QLabel("Better Claude Config") + name_lbl.setObjectName("h1") + title_box.addWidget(name_lbl) + ver_lbl = QLabel(f"Version {core.__version__}") + ver_lbl.setObjectName("muted") + title_box.addWidget(ver_lbl) + head.addLayout(title_box, 1) + v.addLayout(head) + + desc = QLabel( + "A cross-platform GUI for editing the mcpServers block of Claude " + "Desktop and Claude Code configs." + ) + desc.setObjectName("muted") + desc.setWordWrap(True) + v.addWidget(desc) + + if sys.platform == "darwin": + note = QLabel( + "This build isn't notarized by Apple. If macOS blocks it on first " + "launch, right-click the app ▸ Open, or allow it under System " + "Settings ▸ Privacy & Security." + ) + note.setObjectName("muted") + note.setWordWrap(True) + v.addWidget(note) + + links = QHBoxLayout() + for text, url in ( + ("Repository", core.REPO_URL), + ("Issues", core.ISSUES_URL), + ("MIT License", core.LICENSE_URL), + ): + btn = QPushButton(text) + btn.setFlat(True) + btn.setCursor(Qt.CursorShape.PointingHandCursor) + btn.clicked.connect(lambda _=False, u=url: QDesktopServices.openUrl(QUrl(u))) + links.addWidget(btn) + links.addStretch() + v.addLayout(links) + + # --- update check ----------------------------------------------- # + upd_row = QHBoxLayout() + self.check_btn = QPushButton("Check for updates") + self.check_btn.clicked.connect(self._check_for_updates) + upd_row.addWidget(self.check_btn) + self.update_status = QLabel("") + self.update_status.setObjectName("muted") + self.update_status.setWordWrap(True) + upd_row.addWidget(self.update_status, 1) + v.addLayout(upd_row) + + self.release_btn = QPushButton("Open releases page") + self.release_btn.setCursor(Qt.CursorShape.PointingHandCursor) + self.release_btn.clicked.connect(self._open_release_page) + self.release_btn.hide() + v.addWidget(self.release_btn) + + self.auto_check_box = QCheckBox("Automatically check for updates on startup") + st = QSettings("BCC", "BetterClaudeConfig") + self.auto_check_box.setChecked(bool(st.value("update/autoCheck", True, type=bool))) + self.auto_check_box.toggled.connect(self._toggle_auto_check) + v.addWidget(self.auto_check_box) + + btns = QDialogButtonBox(QDialogButtonBox.StandardButton.Close) + btns.rejected.connect(self.reject) + btns.button(QDialogButtonBox.StandardButton.Close).clicked.connect(self.accept) + v.addWidget(btns) + + def _toggle_auto_check(self, on: bool): + QSettings("BCC", "BetterClaudeConfig").setValue("update/autoCheck", on) + + def _check_for_updates(self): + self.check_btn.setEnabled(False) + self.release_btn.hide() + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Checking…") + self._worker = UpdateCheckWorker() + self._worker.done.connect(self._on_check_done) + self._worker.start() + + def _on_check_done(self, release: dict | None): + self.check_btn.setEnabled(True) + self._worker = None + if not release: + self.update_status.setStyleSheet(f"color: {MUTED};") + self.update_status.setText("Couldn't check for updates (offline?).") + return + if core.is_newer_version(core.__version__, release["version"]): + self.update_status.setStyleSheet(f"color: {ACCENT};") + self.update_status.setText(f"{release['version']} available.") + self._release_url = release.get("url") or core.RELEASES_URL + self.release_btn.show() + else: + self.update_status.setStyleSheet(f"color: {GOOD};") + self.update_status.setText("You're up to date.") + + def _open_release_page(self): + QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) + + # --------------------------------------------------------------------------- # # Main window # --------------------------------------------------------------------------- # @@ -1099,6 +1273,7 @@ class MainWindow(QMainWindow): self.setWindowTitle("Better Claude Config") self.resize(940, 640) self.setAcceptDrops(True) + self._build_menu_bar() self.profiles: list[core.Profile] = [] self.full_config: dict = {} @@ -1141,6 +1316,37 @@ class MainWindow(QMainWindow): self._restore_layout() self.reload_profiles() + self._maybe_auto_check_updates() + + # --- menu bar ---------------------------------------------------------- # + def _build_menu_bar(self): + help_menu = self.menuBar().addMenu("&Help") + about_action = QAction("About Better Claude Config…", self) + about_action.triggered.connect(self._show_about) + help_menu.addAction(about_action) + + def _show_about(self): + AboutDialog(self).exec() + + # --- update check (silent, throttled, off-thread) --------------------- # + def _maybe_auto_check_updates(self): + st = QSettings("BCC", "BetterClaudeConfig") + if not bool(st.value("update/autoCheck", True, type=bool)): + return + last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0) + if (time.time() - last) < 86400: # at most once/day + return + st.setValue("update/lastCheck", time.time()) + self._startup_update_worker = UpdateCheckWorker() + self._startup_update_worker.done.connect(self._on_startup_update_checked) + self._startup_update_worker.start() + + def _on_startup_update_checked(self, release: dict | None): + self._startup_update_worker = None + if release and core.is_newer_version(core.__version__, release["version"]): + self.status.setText( + f"Update available: {release['version']} · Help ▸ About to view it." + ) # --- layout persistence ---------------------------------------------- # def _restore_layout(self): From 3cd18392c9e06bebb2da64173d4cb762e280f6af Mon Sep 17 00:00:00 2001 From: the_og Date: Tue, 7 Jul 2026 20:44:00 -0400 Subject: [PATCH 6/6] 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