58 Commits

Author SHA1 Message Date
the_og 9036729cd8 Merge pull request 'fix: MSIX warning gets a persistent banner instead of the status bar (#35)' (#47) from fix/35-msix-banner into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 14s
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 14s
2026-07-12 13:06:41 -04:00
the_og 29a08e9532 Merge pull request 'ci: test on windows + python 3.13 (#40)' (#46) from ci/40-test-matrix into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
2026-07-12 13:06:34 -04:00
Cowork Supervisor 87303809b8 test: dep-check probe command must exist on Windows too
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:05:04 -04:00
Cowork Supervisor 2d274b9e03 fix: MSIX warning gets a persistent banner instead of the status bar (#35)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 10s
CI / Tests (py3.12) (pull_request) Successful in 9s
The status bar is rewritten on every action, so the appended MSIX
warning vanished on first interaction. A dedicated warn banner under
the top bar stays visible.

Closes #35

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:57 -04:00
Cowork Supervisor 62c8a2ea65 ci: use host py launcher for Windows tests (runner blocks setup-python)
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 20s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
The self-hosted Windows runner's PowerShell execution policy rejects
setup-python's install script, so Windows mirrors release.yml: py -3.12
+ venv (the version the release binaries ship with). Linux keeps the
full 3.10/3.12/3.13 setup-python matrix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:02:09 -04:00
the_og b485357cd5 Merge pull request 'fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)' (#45) from fix/39-drop-import-all into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:43 -04:00
the_og 6a91f830dc Merge pull request 'fix: only advance update/lastCheck after a completed check (#37)' (#44) from fix/37-update-lastcheck into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 10s
2026-07-12 13:00:36 -04:00
the_og 6dacc26057 Merge pull request 'fix: harden restart_claude_desktop — Linux refusal, macOS quit-wait, Windows MSIX guard (#33)' (#43) from fix/33-restart-hardening into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:30 -04:00
the_og 1087fc84d1 Merge pull request 'fix: keep UpdateCheckWorker alive until its thread finishes (#32)' (#42) from fix/32-about-worker-lifetime into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:22 -04:00
the_og 5df364fb2e Merge pull request 'fix: spawn_test never raises — str-coerce env/command, wrap unexpected errors (#34)' (#41) from fix/34-spawn-test-env into main
CI / Lint (ruff) (push) Successful in 8s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-12 13:00:14 -04:00
Cowork Supervisor c7b2c90518 fix: spawn_test never raises — coerce env/command to str, wrap unexpected errors (#34)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #34

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:59:19 -04:00
Cowork Supervisor c493aa0c84 fix: harden restart_claude_desktop across platforms (#33)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
- Linux (and any non-desktop platform): refuse instead of 'pkill claude',
  which substring-matched running Claude Code CLI sessions and relaunched
  the CLI, not a desktop app. New restart_supported() gates the button.
- macOS: wait (<=5s) for the old instance to exit before 'open -a Claude'
  so the relaunch can't re-activate the dying process. Runs off the UI
  thread via a RestartWorker.
- Windows: verify the Start-menu shortcut exists BEFORE taskkill, so an
  MSIX/Store install is never killed without a relaunch path.

Closes #33

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:58:15 -04:00
Cowork Supervisor bb355dac31 fix: keep UpdateCheckWorker alive until its thread finishes (#32)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closing the About dialog mid-check GC'd the dialog and the running
QThread with it -> 'QThread: Destroyed while thread is still running'.
A class-level keepalive set now holds each worker until finished;
stale results to a destroyed receiver are dropped by Qt.

Closes #32

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:53:12 -04:00
Cowork Supervisor d95db2b026 fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #39

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:30 -04:00
Cowork Supervisor 42963f98b4 ci: test on windows + python 3.13 (#40)
CI / Tests (py3.10 / windows-latest) (pull_request) Failing after 24s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 14s
CI / Tests (py3.13 / windows-latest) (pull_request) Failing after 10s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 27s
Closes #40

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:14 -04:00
Cowork Supervisor f5c9780948 fix: only advance update/lastCheck after a completed check (#37)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 8s
Closes #37

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:46:59 -04:00
the_og 06326e5e9d fix: app icon in packaged builds (#31, closes #20)
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 12:00:52 -04:00
Cowork Supervisor 6d91c709a7 fix: bundle icons + set app window icon and Windows AppUserModelID (#20)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
2026-07-08 11:59:57 -04:00
the_og 3b5379a2b8 feat: server search/filter + test-all health column (#30, closes #27 #28)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 11:56:37 -04:00
Cowork Supervisor f4d4301c26 Merge remote-tracking branch 'origin/main' into feat/server-list-ux
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-08 11:55:36 -04:00
the_og 5169b7276e feat: MSIX-virtualized Claude config detection (#29, closes #7)
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 8s
CI / Tests (py3.12) (push) Successful in 8s
2026-07-08 11:55:24 -04:00
Cowork Supervisor 668fb903d0 feat: server search/filter + test-all health column (#27, #28)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 7s
2026-07-08 11:53:31 -04:00
Cowork Supervisor 8c456c9a89 feat: detect MSIX-virtualized Claude config path + warn (#7)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-08 11:51:23 -04:00
the_og 4c6fe7c5aa release: v1.2.0 (#26) — merge #6/#8/#9/#17/#18/#19
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10) (push) Successful in 7s
CI / Tests (py3.12) (push) Successful in 8s
Build & Release / Build (Linux) (push) Successful in 59s
Build & Release / Build (Windows) (push) Successful in 1m9s
Build & Release / Build (macOS) (push) Has been cancelled
Build & Release / Publish Release (push) Has been cancelled
2026-07-08 11:34:34 -04:00
Cowork Supervisor 8c718387c0 chore: bump version to 1.2.0 for release
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 22:14:49 -04:00
Cowork Supervisor 15a30fb986 Merge branch 'feat/18-19-about-update-checker' into integration/v1.2.0
# Conflicts:
#	bcc.py
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:13:29 -04:00
Cowork Supervisor c56dec8051 Merge branch 'feat/9-restart-claude-desktop' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:35 -04:00
Cowork Supervisor 70b865be8f Merge branch 'feat/6-log-viewer' into integration/v1.2.0
# Conflicts:
#	bcc_core.py
#	tests/test_core.py
2026-07-07 22:12:17 -04:00
Cowork Supervisor 8fdcbda681 Merge branch 'feat/17-stale-size-hardening' into integration/v1.2.0 2026-07-07 22:10:59 -04:00
the_og 2d9fb083dc Fix: correct smart-quote/nbsp characters mangled in previous push
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:56:14 -04:00
the_og 4ab3c3b00a Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 7s
2026-07-07 20:51:26 -04:00
the_og 3e07b51134 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 7s
2026-07-07 20:48:52 -04:00
the_og a811e323e6 Fix: rebuild from clean main (previous push included unrelated contaminated content)
CI / Lint (ruff) (pull_request) Failing after 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:44:39 -04:00
the_og 3cd18392c9 test: cover __version__, parse_version/is_newer_version, and fetch_latest_release (#19)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 7s
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.
2026-07-07 20:44:00 -04:00
the_og 67c898cd35 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.
2026-07-07 20:42:27 -04:00
the_og 16961a5cc8 test: cover restart_claude_desktop() and profile_targets_claude_desktop() (#9)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 8s
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.
2026-07-07 20:40:57 -04:00
the_og 2b3843a714 feat: add View logs button + LogViewerDialog for in-app MCP log viewer (#6)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10) (pull_request) Successful in 7s
CI / Tests (py3.12) (pull_request) Successful in 8s
2026-07-07 20:40:32 -04:00
the_og 256827eaf3 fix: use \xa0 escape instead of a literal NBSP byte (transmission-safe, same behavior); drop stray trailing blank line 2026-07-07 20:38:35 -04:00
the_og 85d47aea97 test: add server_log_path coverage (macOS, Windows, missing, unsupported) (#6) 2026-07-07 20:36:58 -04:00
the_og f9752211a2 test: add coverage for config_fingerprint mtime+size hardening (#17)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 11s
CI / Tests (py3.12) (pull_request) Successful in 7s
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).
2026-07-07 20:35:03 -04:00
the_og 7c8fd6d0bb fix: use explicit backslash-u-0-0-a-0 escape for non-breaking space literal 2026-07-07 20:34:53 -04:00
the_og f1935fe320 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.
2026-07-07 20:33:38 -04:00
the_og 0ffc6a1fb6 chore: restore trailing newline in bcc_core.py (attempt 2) 2026-07-07 20:32:36 -04:00
the_og fd2c3567a0 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.
2026-07-07 20:32:14 -04:00
the_og 346d0aabb6 fix: use   escape for non-breaking space (byte-exact transcription fix) 2026-07-07 20:30:35 -04:00
the_og 5d59c1c423 fix: restore actual file content (previous commit had placeholder text by mistake) 2026-07-07 20:30:03 -04:00
the_og cffdee8a40 chore: restore trailing newline in bcc_core.py 2026-07-07 20:29:33 -04:00
the_og 0843c51c7d tests/test_core.py: add resolve_name_collision unit tests
CI / Lint (ruff) (pull_request) Failing after 8s
CI / Tests (py3.10) (pull_request) Failing after 8s
CI / Tests (py3.12) (pull_request) Failing after 8s
2026-07-07 20:27:24 -04:00
the_og 82cec27c11 fix: use chr(0xA0) instead of a literal/escaped NBSP
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.
2026-07-07 20:27:17 -04:00
the_og 6c51bac1e0 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.
2026-07-07 20:26:53 -04:00
the_og 4b45251682 fix: actually apply the non-breaking-space escape fix
The previous commit message said this was fixed but the content still
had the literal (unescaped) line. Apply the   escape for real
this time.
2026-07-07 20:26:37 -04:00
the_og 2a0802b22f bcc.py: unify paste/drop collision handling via shared _import_server() prompt 2026-07-07 20:26:00 -04:00
the_og fe66d53e9f feat: __version__ constant + notify-only update checker logic (#19), version source for About dialog (#18)
- 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.
2026-07-07 20:25:33 -04:00
the_og 8d90ab449d feat: add server_log_path() for in-app MCP log viewer (#6) 2026-07-07 20:24:10 -04:00
the_og 9760b1537e fix: restore non-breaking-space literal lost in transcription
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.
2026-07-07 20:23:44 -04:00
the_og 0f1cdbef3c bcc_core.py: fix non-breaking-space byte lost in transcription 2026-07-07 20:22:45 -04:00
the_og 165c65be5f bcc_core.py: add resolve_name_collision() for paste/import name-collision handling 2026-07-07 20:19:25 -04:00
the_og 3c65657d2f 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).
2026-07-07 20:17:57 -04:00
6 changed files with 1965 additions and 54 deletions
+20 -4
View File
@@ -29,21 +29,37 @@ jobs:
run: ruff format --check . run: ruff format --check .
test: test:
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }}) name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python: ["3.10", "3.12"] os: [ubuntu-latest]
python: ["3.10", "3.12", "3.13"]
include:
# Windows tests on 3.12 only — the version the release binaries ship
# with. The self-hosted Windows runner blocks setup-python's install
# script (PowerShell execution policy), so it uses the host's `py`
# launcher + venv, same as release.yml.
- os: windows-latest
python: "3.12"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }} - name: Set up Python ${{ matrix.python }} (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Set up Python venv (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
py -${{ matrix.python }} -m venv .venv
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
# bcc_core has no GUI imports, so the test suite needs no PySide6 — # bcc_core has no GUI imports, so the test suite needs no PySide6 —
# keeps CI fast and avoids Qt system-library headaches on the runner. # keeps CI fast and avoids Qt system-library headaches on the runner.
- name: Install test dependencies - name: Install test dependencies
+669 -43
View File
@@ -11,13 +11,24 @@ Run: python mcp_manager.py
from __future__ import annotations from __future__ import annotations
import sys import sys
import time
from pathlib import Path from pathlib import Path
from typing import ClassVar
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, Signal from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import QAction, QColor, QGuiApplication, QPainter from PySide6.QtGui import (
QAction,
QColor,
QDesktopServices,
QGuiApplication,
QIcon,
QPainter,
QPixmap,
)
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QAbstractItemView, QAbstractItemView,
QApplication, QApplication,
QCheckBox,
QComboBox, QComboBox,
QDialog, QDialog,
QDialogButtonBox, QDialogButtonBox,
@@ -46,6 +57,10 @@ from PySide6.QtWidgets import (
import bcc_core as core import bcc_core as core
# A full ~/.claude.json with history can be huge; parsing happens on the UI
# thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b" ACCENT_DIM = "#c2570b"
@@ -62,6 +77,12 @@ WARN = "#fbbf24"
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN} STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN}
STATUS_GLYPH = {"ok": "", "missing": "", "warn": "", "remote": "", "unknown": ""} STATUS_GLYPH = {"ok": "", "missing": "", "warn": "", "remote": "", "unknown": ""}
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
# server tables' "Health" column -- distinct from the PATH-dependency Status
# column above.
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
HEALTH_GLYPH = {"ok": "", "failed": "", "untested": ""}
STYLESHEET = f""" STYLESHEET = f"""
/* No font-family here on purpose: Qt already uses the native system UI font /* No font-family here on purpose: Qt already uses the native system UI font
on every platform (San Francisco / Segoe UI / desktop default). Naming on every platform (San Francisco / Segoe UI / desktop default). Naming
@@ -104,6 +125,7 @@ QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }} QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
@@ -147,6 +169,43 @@ class SpawnTester(QThread):
self.done.emit(result) 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.
Lifetime: the class keeps every instance alive in `_live` until its
thread has finished. Without this, a caller whose own reference dies
early (the About dialog is a temporary — closing it mid-check used to
GC the dialog and the running QThread with it) crashes the process with
"QThread: Destroyed while thread is still running". Callers may drop
their reference at any time; signal connections to a destroyed receiver
are disconnected by Qt, so a late result is simply discarded.
"""
done = Signal(object) # dict | None
_live: ClassVar[set[UpdateCheckWorker]] = set()
def __init__(self):
super().__init__()
UpdateCheckWorker._live.add(self)
self.finished.connect(self._release_keepalive)
def _release_keepalive(self):
# Delivered on the main thread after run() has returned; only now is
# it safe for the last reference to drop.
UpdateCheckWorker._live.discard(self)
def run(self):
self.done.emit(core.fetch_latest_release())
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Small reusable: key/value editor (for env and headers) # Small reusable: key/value editor (for env and headers)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -374,6 +433,9 @@ class ServerEditor(QFrame):
self.spawn_btn.setToolTip("Spawn the server for 3 s and report whether it starts cleanly") 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.clicked.connect(self._test_spawn)
self.spawn_btn.setVisible(False) 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 = QPushButton("Details ▸")
self.details_btn.setCheckable(True) self.details_btn.setCheckable(True)
self.details_btn.toggled.connect(self._toggle_diag) self.details_btn.toggled.connect(self._toggle_diag)
@@ -384,6 +446,7 @@ class ServerEditor(QFrame):
dep.addWidget(self.fix_btn) dep.addWidget(self.fix_btn)
dep.addWidget(self.test_btn) dep.addWidget(self.test_btn)
dep.addWidget(self.spawn_btn) dep.addWidget(self.spawn_btn)
dep.addWidget(self.logs_btn)
dep.addWidget(self.details_btn) dep.addWidget(self.details_btn)
dep.addWidget(recheck) dep.addWidget(recheck)
outer.addLayout(dep) outer.addLayout(dep)
@@ -728,6 +791,13 @@ class ServerEditor(QFrame):
elif self.diag_card.isVisible(): elif self.diag_card.isVisible():
self.diag_text.setPlainText(self._full_diag_text()) 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 # Arguments editor: one line = one argument, with a numbered gutter so that
@@ -1090,6 +1160,306 @@ class PasteDialog(QDialog):
self.err.setText(str(e)) 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)
# --------------------------------------------------------------------------- #
# Bundled assets (icons) — resolves both a normal source run and a frozen
# PyInstaller build (onefile extracts assets under sys._MEIPASS).
# --------------------------------------------------------------------------- #
def _asset_dir() -> Path:
base = getattr(sys, "_MEIPASS", None)
return Path(base) if base else Path(__file__).resolve().parent
def _app_icon() -> QIcon:
"""Multi-resolution app/window icon from the bundled PNGs (falls back to
the .ico). Returns a null QIcon if no asset is found."""
icon = QIcon()
base = _asset_dir() / "icons" / "twin-gears" / "rounded"
for size in (16, 32, 48, 64, 128, 256, 512):
f = base / f"icon-{size}.png"
if f.is_file():
icon.addFile(str(f))
if icon.isNull():
ico = _asset_dir() / "icons" / "app.ico"
if ico.is_file():
icon.addFile(str(ico))
return icon
# --------------------------------------------------------------------------- #
# About dialog
# --------------------------------------------------------------------------- #
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 = _asset_dir() / "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))
# --------------------------------------------------------------------------- #
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
# waiting for the old instance to exit, so it must run off the UI thread.
# --------------------------------------------------------------------------- #
class RestartWorker(QThread):
done = Signal(object) # core.RestartResult
def run(self):
self.done.emit(core.restart_claude_desktop())
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Main window # Main window
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1099,18 +1469,25 @@ class MainWindow(QMainWindow):
self.setWindowTitle("Better Claude Config") self.setWindowTitle("Better Claude Config")
self.resize(940, 640) self.resize(940, 640)
self.setAcceptDrops(True) self.setAcceptDrops(True)
self._build_menu_bar()
self.profiles: list[core.Profile] = [] self.profiles: list[core.Profile] = []
self.full_config: dict = {} self.full_config: dict = {}
self.servers: list[core.ServerEntry] = [] self.servers: list[core.ServerEntry] = []
self.current_profile: core.Profile | None = None self.current_profile: core.Profile | None = None
self._loaded_mtime: float | None = None self._loaded_stat: core.ConfigStat | None = None
self.dirty = False self.dirty = False
self._suppress_table = False self._suppress_table = False
self._suppress_sel = False self._suppress_sel = False
self._focused_table = None self._focused_table = None
self._row_of_index: dict[int, tuple] = {} self._row_of_index: dict[int, tuple] = {}
self._undo_stack: list[list] = [] # each entry: snapshot of self.servers self._undo_stack: list[list] = [] # each entry: snapshot of self.servers
self._filter_query = ""
self._health: dict[str, tuple[str, str]] = {} # server name -> (HealthStatus, summary)
self._test_all_queue: list[core.ServerEntry] = []
self._test_all_total = 0
self._test_all_done = 0
self._health_tester: SpawnTester | None = None
central = QWidget() central = QWidget()
self.setCentralWidget(central) self.setCentralWidget(central)
@@ -1120,6 +1497,15 @@ class MainWindow(QMainWindow):
root.addLayout(self._build_topbar()) root.addLayout(self._build_topbar())
# Persistent warning banner (MSIX-virtualized config, etc.). Lives in
# its own widget because the status bar is rewritten on every action,
# which used to wipe the warning before the user could read it.
self.warn_banner = QLabel("")
self.warn_banner.setObjectName("warnBanner")
self.warn_banner.setWordWrap(True)
self.warn_banner.hide()
root.addWidget(self.warn_banner)
# User-draggable divider between the server list and the editor. # User-draggable divider between the server list and the editor.
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
@@ -1135,12 +1521,55 @@ class MainWindow(QMainWindow):
root.addLayout(self._build_actionbar()) root.addLayout(self._build_actionbar())
status_row = QHBoxLayout()
status_row.setContentsMargins(0, 0, 0, 0)
self.status = QLabel("Ready.") self.status = QLabel("Ready.")
self.status.setObjectName("statusbar") 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._restore_layout()
self.reload_profiles() 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
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 is None:
return # offline/failed check: don't advance lastCheck, allow retry
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
if core.is_newer_version(core.__version__, release["version"]):
self.status.setText(
f"Update available: {release['version']} · Help ▸ About to view it."
)
# --- layout persistence ---------------------------------------------- # # --- layout persistence ---------------------------------------------- #
def _restore_layout(self): def _restore_layout(self):
@@ -1187,10 +1616,10 @@ class MainWindow(QMainWindow):
# --- left (server table) -------------------------------------------- # # --- left (server table) -------------------------------------------- #
def _make_server_table(self, object_name=None): def _make_server_table(self, object_name=None):
t = QTableWidget(0, 4) t = QTableWidget(0, 5)
if object_name: if object_name:
t.setObjectName(object_name) t.setObjectName(object_name)
t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status"]) t.setHorizontalHeaderLabels(["On", "Name", "Type", "Status", "Health"])
t.verticalHeader().setVisible(False) t.verticalHeader().setVisible(False)
t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) t.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) t.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
@@ -1200,6 +1629,7 @@ class MainWindow(QMainWindow):
h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) h.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(2, QHeaderView.ResizeMode.ResizeToContents)
h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents) h.setSectionResizeMode(3, QHeaderView.ResizeMode.ResizeToContents)
h.setSectionResizeMode(4, QHeaderView.ResizeMode.ResizeToContents)
t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl)) t.itemSelectionChanged.connect(lambda tbl=t: self._on_selection(tbl))
t.itemChanged.connect(self._table_item_changed) t.itemChanged.connect(self._table_item_changed)
return t return t
@@ -1214,6 +1644,12 @@ class MainWindow(QMainWindow):
head.setObjectName("h1") head.setObjectName("h1")
v.addWidget(head) v.addWidget(head)
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._on_search_changed)
v.addWidget(self.search_box)
# Active and Disabled sections live in a vertical splitter so the user # Active and Disabled sections live in a vertical splitter so the user
# can drag the divider instead of being stuck with a fixed-height # can drag the divider instead of being stuck with a fixed-height
# disabled list. # disabled list.
@@ -1270,12 +1706,17 @@ class MainWindow(QMainWindow):
self.undo_btn = QPushButton("Undo") self.undo_btn = QPushButton("Undo")
self.undo_btn.setEnabled(False) self.undo_btn.setEnabled(False)
self.undo_btn.setToolTip("Undo last change (Ctrl+Z)") self.undo_btn.setToolTip("Undo last change (Ctrl+Z)")
self.test_all_btn = QPushButton("Test all")
self.test_all_btn.setToolTip(
"Spawn-test every enabled local server, one at a time, and fill in the Health column"
)
self.add_btn.clicked.connect(self.add_server) self.add_btn.clicked.connect(self.add_server)
self.dup_btn.clicked.connect(self.duplicate_server) self.dup_btn.clicked.connect(self.duplicate_server)
self.del_btn.clicked.connect(self.delete_server) self.del_btn.clicked.connect(self.delete_server)
self.paste_btn.clicked.connect(self.paste_json) self.paste_btn.clicked.connect(self.paste_json)
self.copy_btn.clicked.connect(self.copy_to_menu) self.copy_btn.clicked.connect(self.copy_to_menu)
self.undo_btn.clicked.connect(self._undo) self.undo_btn.clicked.connect(self._undo)
self.test_all_btn.clicked.connect(self._test_all_servers)
for b in ( for b in (
self.add_btn, self.add_btn,
self.dup_btn, self.dup_btn,
@@ -1283,6 +1724,7 @@ class MainWindow(QMainWindow):
self.paste_btn, self.paste_btn,
self.copy_btn, self.copy_btn,
self.undo_btn, self.undo_btn,
self.test_all_btn,
): ):
bar.addWidget(b) bar.addWidget(b)
# Ctrl+Z shortcut # Ctrl+Z shortcut
@@ -1333,6 +1775,26 @@ class MainWindow(QMainWindow):
self.load_profile(self.profiles[0]) self.load_profile(self.profiles[0])
else: else:
self.status.setText('No Claude installs found. Use "Add config…" to point at one.') self.status.setText('No Claude installs found. Use "Add config…" to point at one.')
self._maybe_warn_msix()
def _maybe_warn_msix(self):
"""
Windows-only, no-op everywhere else: if Claude Desktop looks like an
MSIX/Store install with a virtualized config, show a persistent banner
so edits to the plain %APPDATA% path aren't silently lost. (The status
bar is the wrong home for this: it's rewritten on every action.)
Defensive on purpose -- this must never block startup or profile load.
"""
try:
warning = core.msix_warning_text()
except Exception:
return
if warning:
self.warn_banner.setText(f"{warning}")
self.warn_banner.setToolTip(warning)
self.warn_banner.show()
else:
self.warn_banner.hide()
def add_custom_config(self): def add_custom_config(self):
start = str(core.app_support_base()) start = str(core.app_support_base())
@@ -1380,12 +1842,14 @@ class MainWindow(QMainWindow):
return return
self.full_config = cfg self.full_config = cfg
repaired = True repaired = True
self._loaded_mtime = core.config_mtime(profile.path) self._loaded_stat = core.config_fingerprint(profile.path)
self.current_profile = profile self.current_profile = profile
self.servers = core.extract_servers(self.full_config) self.servers = core.extract_servers(self.full_config)
self.dirty = False self.dirty = False
self.restart_btn.hide()
self._undo_stack.clear() self._undo_stack.clear()
self.undo_btn.setEnabled(False) self.undo_btn.setEnabled(False)
self._health.clear() # health results are per-profile; a fresh load invalidates them
self._refresh_tables(select_index=0 if self.servers else -1) self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False) self._update_status(saved=False)
if repaired: if repaired:
@@ -1422,6 +1886,14 @@ class MainWindow(QMainWindow):
QColor(STATUS_COLORS.get(dep["status"], MUTED)) QColor(STATUS_COLORS.get(dep["status"], MUTED))
) # status stays colored even when off ) # status stays colored even when off
table.setItem(r, 3, st) table.setItem(r, 3, st)
health_status, health_summary = self._health.get(s.name, (core.HealthStatus.UNTESTED, ""))
if s.kind == "remote" and s.name not in self._health:
health_summary = "remote server — use “Test connection” in the editor"
health_item = QTableWidgetItem(HEALTH_GLYPH.get(health_status, ""))
health_item.setForeground(QColor(HEALTH_COLORS.get(health_status, MUTED)))
health_item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
health_item.setToolTip(health_summary or health_status)
table.setItem(r, 4, health_item)
self._row_of_index[master_idx] = (table, r) self._row_of_index[master_idx] = (table, r)
def _refresh_tables(self, select_index=None): def _refresh_tables(self, select_index=None):
@@ -1430,8 +1902,16 @@ class MainWindow(QMainWindow):
self._row_of_index = {} self._row_of_index = {}
self.active_table.setRowCount(0) self.active_table.setRowCount(0)
self.disabled_table.setRowCount(0) self.disabled_table.setRowCount(0)
query = self._filter_query
n_active = n_disabled = 0 n_active = n_disabled = 0
total_active = total_disabled = 0
for i, s in enumerate(self.servers): for i, s in enumerate(self.servers):
if s.enabled:
total_active += 1
else:
total_disabled += 1
if not core.server_matches_filter(s, query):
continue
if s.enabled: if s.enabled:
self._add_row(self.active_table, i, s) self._add_row(self.active_table, i, s)
n_active += 1 n_active += 1
@@ -1440,8 +1920,18 @@ class MainWindow(QMainWindow):
n_disabled += 1 n_disabled += 1
self.active_table.setVisible(n_active > 0) self.active_table.setVisible(n_active > 0)
self.active_empty.setVisible(n_active == 0) self.active_empty.setVisible(n_active == 0)
self.active_empty.setText(
"No matches."
if query.strip() and total_active and not n_active
else "No active servers. Add one, or Paste JSON."
)
self.disabled_table.setVisible(n_disabled > 0) self.disabled_table.setVisible(n_disabled > 0)
self.disabled_empty.setVisible(n_disabled == 0) self.disabled_empty.setVisible(n_disabled == 0)
self.disabled_empty.setText(
"No matches."
if query.strip() and total_disabled and not n_disabled
else "Nothing disabled."
)
self._refresh_badges() self._refresh_badges()
self._suppress_table = False self._suppress_table = False
self._suppress_sel = False self._suppress_sel = False
@@ -1453,6 +1943,58 @@ class MainWindow(QMainWindow):
self._load_editor_from_selection() self._load_editor_from_selection()
self._validate() self._validate()
# --- search / filter -------------------------------------------------- #
def _on_search_changed(self, text):
self._filter_query = text
cur = self._current_index()
self._refresh_tables(select_index=cur if cur >= 0 else None)
# --- test all (spawn-test every enabled local server) ---------------- #
def _test_all_servers(self):
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
if not targets:
self.status.setText("No enabled local servers to test.")
return
self._test_all_queue = list(targets)
self._test_all_total = len(targets)
self._test_all_done = 0
self.test_all_btn.setEnabled(False)
self.test_all_btn.setText(f"Testing 0/{self._test_all_total}")
self._run_next_health_test()
def _run_next_health_test(self):
if not self._test_all_queue:
self.test_all_btn.setEnabled(True)
self.test_all_btn.setText("Test all")
self.status.setText(f"Tested {self._test_all_done} server(s).")
return
entry = self._test_all_queue.pop(0)
self._health_tester = SpawnTester(dict(entry.data), timeout=3.0)
self._health_tester.done.connect(
lambda result, name=entry.name: self._on_health_test_done(name, result)
)
self._health_tester.start()
def _on_health_test_done(self, name, result):
status, summary = core.health_from_spawn_result(result)
self._health[name] = (status, summary)
self._test_all_done += 1
self.test_all_btn.setText(f"Testing {self._test_all_done}/{self._test_all_total}")
self._update_health_cell(name, status, summary)
self._run_next_health_test()
def _update_health_cell(self, name, status, summary):
idx = next((i for i, s in enumerate(self.servers) if s.name == name), None)
if idx is None or idx not in self._row_of_index:
return
table, row = self._row_of_index[idx]
item = table.item(row, 4)
if item is None:
return
item.setText(HEALTH_GLYPH.get(status, ""))
item.setForeground(QColor(HEALTH_COLORS.get(status, MUTED)))
item.setToolTip(summary or status)
def _section_html(self, title, n, miss, warn): def _section_html(self, title, n, miss, warn):
base = f"{title} · {n}" base = f"{title} · {n}"
if miss: if miss:
@@ -1559,6 +2101,9 @@ class MainWindow(QMainWindow):
entry.data = self.editor.dump_data() entry.data = self.editor.dump_data()
# The server stays in its section (enable state unchanged), so update # The server stays in its section (enable state unchanged), so update
# its existing row in place rather than re-rendering. # its existing row in place rather than re-rendering.
# An edit invalidates any cached "Test all" result -- the server that
# was spawn-tested no longer matches what's on disk once saved.
self._health.pop(entry.name, None)
loc = self._row_of_index.get(idx) loc = self._row_of_index.get(idx)
if loc: if loc:
table, row = loc table, row = loc
@@ -1569,6 +2114,13 @@ class MainWindow(QMainWindow):
st = table.item(row, 3) st = table.item(row, 3)
st.setText(f"{STATUS_GLYPH.get(dep['status'], '')} {dep['label']}") st.setText(f"{STATUS_GLYPH.get(dep['status'], '')} {dep['label']}")
st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED)))
health_item = table.item(row, 4)
if health_item is not None:
health_item.setText(HEALTH_GLYPH.get(core.HealthStatus.UNTESTED, ""))
health_item.setForeground(
QColor(HEALTH_COLORS.get(core.HealthStatus.UNTESTED, MUTED))
)
health_item.setToolTip("Not tested since last edit.")
self._suppress_table = False self._suppress_table = False
self._refresh_badges() self._refresh_badges()
self._mark_dirty() self._mark_dirty()
@@ -1620,34 +2172,38 @@ class MainWindow(QMainWindow):
self._refresh_tables(select_index=min(idx, len(self.servers) - 1)) self._refresh_tables(select_index=min(idx, len(self.servers) - 1))
self._mark_dirty() 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): def paste_json(self):
dlg = PasteDialog(self) dlg = PasteDialog(self)
if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers: if dlg.exec() != QDialog.DialogCode.Accepted or not dlg.result_servers:
return return
self._push_undo() self._push_undo()
added, replaced = 0, 0 added, replaced = 0, 0
existing = {s.name: i for i, s in enumerate(self.servers)}
for name, data in dlg.result_servers.items(): for name, data in dlg.result_servers.items():
if name in existing: a, r = self._import_server(name, data)
ans = QMessageBox.question( added += int(a)
self, replaced += int(r)
"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
self._refresh_tables(select_index=len(self.servers) - 1) self._refresh_tables(select_index=len(self.servers) - 1)
self._mark_dirty() self._mark_dirty()
self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.") self.status.setText(f"Imported {added} added, {replaced} replaced. Review and Save.")
@@ -1719,11 +2275,14 @@ class MainWindow(QMainWindow):
return return
# Stale-file check: if the file changed on disk since we loaded it, prompt. # 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 ( if (
disk_mtime is not None disk_stat is not None
and self._loaded_mtime is not None and self._loaded_stat is not None
and disk_mtime != self._loaded_mtime and disk_stat != self._loaded_stat
): ):
changed_keys, server_diff = core.external_change_summary( changed_keys, server_diff = core.external_change_summary(
self.full_config, self.current_profile.path self.full_config, self.current_profile.path
@@ -1744,7 +2303,7 @@ class MainWindow(QMainWindow):
QMessageBox.critical(self, "Save failed", str(e)) QMessageBox.critical(self, "Save failed", str(e))
return return
self.full_config = fresh 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.current_profile.config_exists = True
self.dirty = False self.dirty = False
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
@@ -1753,6 +2312,7 @@ class MainWindow(QMainWindow):
f"Merged & saved {self.current_profile.path}{bnote}" f"Merged & saved {self.current_profile.path}{bnote}"
f" · Restart {self.current_profile.label} to apply." f" · Restart {self.current_profile.label} to apply."
) )
self._offer_restart_button()
return return
# else OVERWRITE: fall through to normal write # else OVERWRITE: fall through to normal write
@@ -1762,7 +2322,7 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Save failed", str(e)) QMessageBox.critical(self, "Save failed", str(e))
return 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.current_profile.config_exists = True
self.dirty = False self.dirty = False
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
@@ -1770,6 +2330,41 @@ class MainWindow(QMainWindow):
self.status.setText( self.status.setText(
f"Saved {self.current_profile.path}{bnote} · Restart {self.current_profile.label} to apply." 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 --
and only on platforms where Claude Desktop exists (never Linux, where
'claude' is the Claude Code CLI)."""
if (
self.current_profile
and core.profile_targets_claude_desktop(self.current_profile)
and core.restart_supported()
):
self.restart_btn.show()
else:
self.restart_btn.hide()
def _restart_claude_desktop(self):
self.restart_btn.setEnabled(False)
self.restart_btn.setText("Restarting…")
# Held on self (MainWindow outlives the worker); replaced only after
# done re-enables the button, so a running thread is never dropped.
self._restart_worker = RestartWorker()
self._restart_worker.done.connect(self._on_restart_done)
self._restart_worker.start()
def _on_restart_done(self, result):
self.restart_btn.setEnabled(True)
self.restart_btn.setText("Restart Claude Desktop")
self.restart_btn.hide()
if result.success:
self.status.setText(f"{self.status.text()} · {result.detail}")
else:
QMessageBox.warning(self, "Restart failed", result.detail)
def _restore_from_backup(self): def _restore_from_backup(self):
if not self.current_profile: if not self.current_profile:
@@ -1798,6 +2393,7 @@ class MainWindow(QMainWindow):
# --- dirty / status -------------------------------------------------- # # --- dirty / status -------------------------------------------------- #
def _mark_dirty(self): def _mark_dirty(self):
self.dirty = True self.dirty = True
self.restart_btn.hide()
self._validate() self._validate()
self._update_status(saved=False) self._update_status(saved=False)
@@ -1824,28 +2420,44 @@ class MainWindow(QMainWindow):
e.acceptProposedAction() e.acceptProposedAction()
def dropEvent(self, e): def dropEvent(self, e):
total_added, total_replaced, files_imported = 0, 0, 0
undo_pushed = False
for u in e.mimeData().urls(): for u in e.mimeData().urls():
path = u.toLocalFile() path = u.toLocalFile()
if not path.endswith(".json"): if not path.endswith(".json"):
continue continue
text = Path(path).read_text(encoding="utf-8") p = Path(path)
if p.stat().st_size > MAX_DROP_IMPORT_BYTES:
QMessageBox.warning(
self,
"File too large",
f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.",
)
continue
text = p.read_text(encoding="utf-8")
try: try:
servers = core.parse_pasted_json(text) servers = core.parse_pasted_json(text)
except Exception as ex: except Exception as ex:
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}") QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}")
continue continue
self._push_undo() if not undo_pushed:
self._push_undo()
undo_pushed = True
added, replaced = 0, 0
for name, data in servers.items(): for name, data in servers.items():
names = {s.name for s in self.servers} a, r = self._import_server(name, data)
if name in names: added += int(a)
name = f"{name}-imported" replaced += int(r)
self.servers.append(core.ServerEntry(name, data, True)) total_added += added
total_replaced += replaced
files_imported += 1
if files_imported:
self._refresh_tables(select_index=len(self.servers) - 1) self._refresh_tables(select_index=len(self.servers) - 1)
self._mark_dirty() self._mark_dirty()
self.status.setText( self.status.setText(
f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save." f"Imported {total_added} added, {total_replaced} replaced from "
f"{files_imported} file(s). Review and Save."
) )
break
def closeEvent(self, e): def closeEvent(self, e):
if self.dirty and not self._confirm_discard(): if self.dirty and not self._confirm_discard():
@@ -1856,9 +2468,23 @@ class MainWindow(QMainWindow):
def main(): def main():
if sys.platform == "win32":
# Without an explicit AppUserModelID, Windows taskbar groups the app
# under the default host/Python icon instead of our own window icon.
try:
import ctypes
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
"io.avezzano.better-claude-config"
)
except Exception:
pass
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setApplicationName("Better Claude Config") app.setApplicationName("Better Claude Config")
app.setApplicationDisplayName("Better Claude Config") app.setApplicationDisplayName("Better Claude Config")
icon = _app_icon()
if not icon.isNull():
app.setWindowIcon(icon)
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
win = MainWindow() win = MainWindow()
win.show() win.show()
+1 -1
View File
@@ -31,7 +31,7 @@ a = Analysis(
["bcc.py"], ["bcc.py"],
pathex=[], pathex=[],
binaries=[], binaries=[],
datas=[], datas=[("icons", "icons")],
hiddenimports=[], hiddenimports=[],
hookspath=[], hookspath=[],
hooksconfig={}, hooksconfig={},
+496 -3
View File
@@ -29,6 +29,7 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse from urllib.parse import urlparse
CONFIG_FILENAME = "claude_desktop_config.json" CONFIG_FILENAME = "claude_desktop_config.json"
@@ -46,6 +47,112 @@ MAX_BACKUPS = 15
KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} 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.2.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": "<tag>", "url": "<releases page>"} 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 # Data model
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -84,6 +191,97 @@ def app_support_base() -> Path:
return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
def msix_config_paths(localappdata: str | os.PathLike | None = None) -> list[Path]:
"""
Find MSIX/Store-packaged Claude Desktop configs.
When Claude Desktop is installed from the Microsoft Store (MSIX), Windows
virtualizes its filesystem writes to a per-package folder under
`%LOCALAPPDATA%\\Packages\\<PackageFamilyName>\\LocalCache\\Roaming\\Claude\\`
instead of the normal `%APPDATA%\\Claude\\`. A user (or BCC) editing the
plain %APPDATA% path can end up changing a file the running app never
reads -- see anthropics/claude-code issues #26073, #29100, #38830.
Globs `<localappdata>/Packages/*Claude*/LocalCache/Roaming/Claude/
claude_desktop_config.json` and returns every match that actually exists,
sorted for determinism. `localappdata` defaults to the %LOCALAPPDATA% env
var (falling back to the usual Windows path) but is accepted as a
parameter so this is unit-testable with tmp_path on any platform.
This function itself is platform-independent (it just globs whatever
directory it's given); callers that care about the *current* machine
should gate on sys.platform -- see `detect_msix_claude`.
"""
base = (
Path(localappdata)
if localappdata is not None
else Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local")))
)
packages = base / "Packages"
if not packages.is_dir():
return []
out: list[Path] = []
for pkg_dir in sorted(packages.glob("*Claude*")):
cfg = pkg_dir / "LocalCache" / "Roaming" / "Claude" / CONFIG_FILENAME
if cfg.is_file():
out.append(cfg)
return out
def detect_msix_claude(
appdata: str | os.PathLike | None = None,
localappdata: str | os.PathLike | None = None,
) -> Path | None:
"""
Best-effort detection of an MSIX-virtualized Claude Desktop install.
Returns the first virtualized `claude_desktop_config.json` found (see
`msix_config_paths`), or None when not running on Windows, no matching
package folder exists, or a package folder exists but has no config file
written yet. The sys.platform gate makes this a safe no-op to call
unconditionally from discovery/diagnostics code on macOS/Linux.
`appdata`/`localappdata` are threaded through (rather than read straight
from os.environ) purely so the whole detection path is unit-testable via
tmp_path + monkeypatch without mutating real env vars.
"""
if not sys.platform.startswith("win"):
return None
hits = msix_config_paths(localappdata)
return hits[0] if hits else None
def msix_warning_text(
appdata: str | os.PathLike | None = None,
localappdata: str | os.PathLike | None = None,
) -> str | None:
"""
A one-line, paste-safe warning for the diagnostics/status surface when
Claude Desktop looks like an MSIX/Store install whose real config lives
somewhere other than the plain %APPDATA%\\Claude\\ path. Returns None
when nothing was detected (including on non-Windows platforms) or when
the virtualized path and the plain path happen to coincide -- i.e. there
is nothing surprising to warn about. Contains only filesystem paths, no
env values or secrets.
"""
real = detect_msix_claude(appdata, localappdata)
if real is None:
return None
plain_base = (
Path(appdata)
if appdata is not None
else Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
)
plain_cfg = plain_base / "Claude" / CONFIG_FILENAME
if plain_cfg == real:
return None
return (
"Claude Desktop looks like it's installed from the Microsoft Store (MSIX). "
f"Windows virtualizes its config, so edits to {plain_cfg} may be silently "
f"ignored by the running app. The real config is at: {real}"
)
def discover_profiles() -> list[Profile]: def discover_profiles() -> list[Profile]:
""" """
Find every `Claude*` data directory in the platform's app-support base Find every `Claude*` data directory in the platform's app-support base
@@ -91,6 +289,11 @@ def discover_profiles() -> list[Profile]:
Claude Desktop: scans the platform app-support folder for any `Claude*` Claude Desktop: scans the platform app-support folder for any `Claude*`
directory (catches `Claude`, `Claude-Work`, etc.). directory (catches `Claude`, `Claude-Work`, etc.).
Windows/MSIX: if Claude Desktop was installed from the Microsoft Store,
its real config lives in a virtualized per-package folder rather than the
plain %APPDATA%\\Claude\\ path above (see `detect_msix_claude`); when
that's detected, it's surfaced here as its own profile so the user can
edit the file the app actually reads.
Claude Code: user-scope MCP servers live in ~/.claude.json (that's what Claude 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 `claude mcp add` writes; project scope is a per-repo .mcp.json, which can
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
@@ -106,6 +309,12 @@ def discover_profiles() -> list[Profile]:
cfg = d / CONFIG_FILENAME cfg = d / CONFIG_FILENAME
out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file())) out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file()))
msix_cfg = detect_msix_claude()
if msix_cfg is not None and str(msix_cfg) not in {str(p.path) for p in out}:
out.append(
Profile(label="Claude (Microsoft Store / MSIX)", path=msix_cfg, config_exists=True)
)
home = Path.home() home = Path.home()
cc_cfg = home / ".claude.json" cc_cfg = home / ".claude.json"
out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file()))
@@ -135,6 +344,17 @@ def profile_from_path(path: str | os.PathLike) -> Profile:
return Profile(label=label, path=p, config_exists=p.is_file()) 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 # Load / extract / apply
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -189,6 +409,25 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
return out 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: def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict:
""" """
Write the server list back into `cfg` in place, preserving every other key Write the server list back into `cfg` in place, preserving every other key
@@ -380,6 +619,30 @@ def config_mtime(path: Path | str) -> float | None:
return 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]: 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. Compare original_cfg (what BCC loaded) with the current on-disk state.
@@ -474,7 +737,7 @@ def _normalize_unicode(text: str, notes: list[str]) -> str:
out = text out = text
for junk in _JUNK_CHARS: for junk in _JUNK_CHARS:
out = out.replace(junk, "") out = out.replace(junk, "")
out = out.replace(" ", " ") # non-breaking space out = out.replace(chr(0xA0), " ") # non-breaking space (defensive: avoid a literal char here)
for smart, ascii_q in _QUOTE_MAP.items(): for smart, ascii_q in _QUOTE_MAP.items():
out = out.replace(smart, ascii_q) out = out.replace(smart, ascii_q)
if out != text: if out != text:
@@ -950,6 +1213,32 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
return problems return problems
# --------------------------------------------------------------------------- #
# Search / filter
# --------------------------------------------------------------------------- #
def server_matches_filter(entry: ServerEntry, query: str) -> bool:
"""
Case-insensitive substring match against a server's name, and its
command (stdio) or url (remote). An empty/whitespace-only query matches
everything -- that's what lets the search box double as "no filter".
"""
q = (query or "").strip().lower()
if not q:
return True
if q in entry.name.lower():
return True
if entry.kind == "remote":
haystack = str(entry.data.get("url", ""))
else:
haystack = str(entry.data.get("command", ""))
return q in haystack.lower()
def filter_servers(entries: list[ServerEntry], query: str) -> list[ServerEntry]:
"""Return only the entries that match `query` (see server_matches_filter)."""
return [e for e in entries if server_matches_filter(e, query)]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Dependency / PATH checking # Dependency / PATH checking
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1270,6 +1559,25 @@ def diagnostics_text(name: str, data: dict) -> str:
return "\n".join(L) 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-<name>.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 _STDERR_CAP = 4096 # bytes
@@ -1283,12 +1591,29 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
"crashed" — exited with a non-zero code before timeout "crashed" — exited with a non-zero code before timeout
"not_found" — command could not be resolved to an executable "not_found" — command could not be resolved to an executable
"not_applicable" — remote server or no command; nothing to spawn "not_applicable" — remote server or no command; nothing to spawn
"error" — unexpected internal failure while spawning/observing
returncode: int | None returncode: int | None
stderr: str (first ~4 KB) stderr: str (first ~4 KB)
detail: str detail: str
Never raises: the GUI threads (Test launch / Test all) re-enable their
buttons only when a result arrives, so an escaping exception would leave
the UI stuck. Anything unexpected comes back as outcome "error".
Run this off the UI thread — it blocks for up to `timeout` seconds. Run this off the UI thread — it blocks for up to `timeout` seconds.
""" """
try:
return _spawn_test_impl(data, timeout)
except Exception as e:
return {
"outcome": "error",
"returncode": None,
"stderr": "",
"detail": f"unexpected error: {e!r}",
}
def _spawn_test_impl(data: dict, timeout: float) -> dict:
if "url" in data and "command" not in data: if "url" in data and "command" not in data:
return { return {
"outcome": "not_applicable", "outcome": "not_applicable",
@@ -1297,7 +1622,9 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
"detail": "remote server", "detail": "remote server",
} }
cmd = (data.get("command") or "").strip() # str() first: pasted JSON can legally carry a non-string here and the
# value never round-trips through the editor before a Test all run.
cmd = str(data.get("command") or "").strip()
if not cmd: if not cmd:
return { return {
"outcome": "not_applicable", "outcome": "not_applicable",
@@ -1319,7 +1646,8 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])] args_list = [resolved_cmd] + [str(a) for a in (data.get("args") or [])]
merged_env = {**os.environ, "PATH": augmented_path()} merged_env = {**os.environ, "PATH": augmented_path()}
merged_env.update(data.get("env") or {}) # Popen rejects non-string env values; pasted JSON may carry numbers.
merged_env.update({str(k): str(v) for k, v in (data.get("env") or {}).items()})
stderr_chunks: list[bytes] = [] stderr_chunks: list[bytes] = []
@@ -1400,6 +1728,53 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
} }
# --------------------------------------------------------------------------- #
# Health status (maps a spawn_test() result to a simple tri-state for the
# server-list UI's per-row status dot; see "Test all" in bcc.py)
# --------------------------------------------------------------------------- #
class HealthStatus:
"""
Tri-state health for the server-list status dot. A plain class of string
constants -- not an Enum -- to match the plain-string status values used
elsewhere in this module (see check_dependency's 'status').
"""
UNTESTED = "untested"
OK = "ok"
FAILED = "failed"
def health_from_spawn_result(result: dict) -> tuple[str, str]:
"""
Map a spawn_test() result dict to (HealthStatus, short_summary) for the
server-list status column. Reuses spawn_test's own outcome classification
rather than re-deriving pass/fail from returncode/stderr:
outcome "ok" -> OK (server started and kept running)
outcome "not_applicable" -> UNTESTED (remote server, or no command set)
anything else -> FAILED (exited, crashed, or not found)
The summary is short enough for a table cell/tooltip; when the process
wrote to stderr before dying, its first line is appended for context.
"""
outcome = result.get("outcome", "")
detail = result.get("detail", "") or ""
stderr = (result.get("stderr") or "").strip()
if outcome == "ok":
return HealthStatus.OK, detail or "started"
if outcome == "not_applicable":
return HealthStatus.UNTESTED, detail or "not applicable"
# exited / crashed / not_found: the server didn't come up cleanly.
summary = detail or outcome
if stderr:
first_line = stderr.splitlines()[0].strip()
if first_line:
summary = f"{summary}{first_line}"
return HealthStatus.FAILED, summary
def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]: 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) Reachability check for a url-based MCP server. ANY HTTP response (even 4xx/5xx)
@@ -1459,3 +1834,121 @@ def pin_command_path(data: dict, path: str | None = None) -> tuple[dict, str | N
out["args"] = args out["args"] = args
return out, f"'{c}'{resolved}" return out, f"'{c}'{resolved}"
return data, None 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_supported() -> bool:
"""
True only where restarting Claude Desktop makes sense (macOS, Windows).
There is no official Claude Desktop for Linux, and the obvious binary
name there ("claude") is the Claude Code CLI — killing or spawning it
would be actively harmful. GUI callers gate the Restart button on this.
"""
return sys.platform == "darwin" or sys.platform.startswith("win")
_MACOS_QUIT_WAIT_S = 5.0
def _macos_claude_running() -> bool:
try:
return subprocess.run(["pgrep", "-x", "Claude"], capture_output=True).returncode == 0
except OSError:
return False
def _restart_claude_desktop_macos() -> RestartResult:
_run_quiet(["pkill", "-x", "Claude"])
# Wait for the old instance to actually exit: `open -a` against a dying
# process can merely re-activate it, and the config is only re-read on a
# true relaunch. Blocks up to _MACOS_QUIT_WAIT_S — callers run this off
# the UI thread (see RestartWorker in bcc.py).
deadline = time.monotonic() + _MACOS_QUIT_WAIT_S
while _macos_claude_running():
if time.monotonic() > deadline:
return RestartResult(
False,
f"Claude Desktop didn't quit within {_MACOS_QUIT_WAIT_S:.0f}s — "
"quit it manually, then reopen it.",
)
time.sleep(0.15)
try:
result = subprocess.run(["open", "-a", "Claude"], capture_output=True, text=True)
except OSError as e:
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:
shortcut = _claude_windows_start_menu_shortcut()
if not shortcut.is_file():
# Checked BEFORE killing: an MSIX/Store install has no Start-menu .lnk
# at this path, and killing without a relaunch path would leave the
# user with no running Claude at all.
return RestartResult(
False,
f"Claude's Start-menu shortcut wasn't found ({shortcut}). "
"If Claude Desktop is installed from the Microsoft Store, "
"quit and reopen it manually.",
)
_run_quiet(["taskkill", "/IM", "Claude.exe", "/F"])
try:
# `cmd /c start "" <target>` launches detached, the same as double-clicking
# the Start-menu shortcut, and returns immediately.
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() -> RestartResult:
"""
Kill and relaunch the Claude Desktop app so a freshly saved config takes
effect. The app not currently running is NOT a failure -- pkill/taskkill
exiting non-zero just means "nothing to kill", and we go straight to
relaunching. Only a failed relaunch is reported as success=False.
macOS blocks for up to _MACOS_QUIT_WAIT_S while the old instance exits —
run off the UI thread. Unsupported platforms (see restart_supported())
refuse without touching any process.
"""
if sys.platform == "darwin":
return _restart_claude_desktop_macos()
if sys.platform.startswith("win"):
return _restart_claude_desktop_windows()
return RestartResult(False, "Restarting Claude Desktop isn't supported on this platform.")
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "better-claude-config" 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" description = "Cross-platform GUI for editing the mcpServers block of Claude Desktop and Claude Code configs"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
+778 -2
View File
@@ -2,7 +2,10 @@
proper test functions with tmp_path/monkeypatch fixtures).""" proper test functions with tmp_path/monkeypatch fixtures)."""
import json import json
import os
import sys import sys
import urllib.error
import urllib.request
import pytest import pytest
@@ -224,8 +227,11 @@ def test_diagnostics_redacts_token_args():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 6. Dependency / PATH checking # 6. Dependency / PATH checking
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dep_check_finds_python3(): def test_dep_check_finds_python():
assert c.check_dependency({"command": "python3"})["status"] == "ok" # "python3" does not exist on a stock Windows install; "warn" (found only
# on an augmented PATH) still proves resolution works on a real machine.
cmd = "python" if os.name == "nt" else "python3"
assert c.check_dependency({"command": cmd})["status"] in ("ok", "warn")
def test_dep_check_flags_missing(): def test_dep_check_flags_missing():
@@ -323,6 +329,42 @@ def test_spawn_test_large_stderr_does_not_deadlock():
assert len(r["stderr"]) <= c._STDERR_CAP assert len(r["stderr"]) <= c._STDERR_CAP
def test_spawn_test_non_string_env_value():
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
# round-trip through the editor. Popen rejects non-str env; spawn_test must
# coerce instead of letting TypeError escape (which would leave the GUI's
# Test launch / Test all buttons stuck disabled).
r = c.spawn_test(
{
"command": sys.executable,
"args": ["-c", "import os; raise SystemExit(0 if os.environ['PORT'] == '8080' else 1)"],
"env": {"PORT": 8080},
},
timeout=2.0,
)
assert r["outcome"] == "exited"
assert r["returncode"] == 0
def test_spawn_test_non_string_command():
# A non-string command must classify, not raise (str(123) resolves to nothing).
r = c.spawn_test({"command": 123}, timeout=0.3)
assert r["outcome"] == "not_found"
def test_spawn_test_internal_error_yields_result(monkeypatch):
# Any unexpected exception inside the spawn path must come back as a result
# dict (outcome "error"), never escape — the UI only re-enables its buttons
# when a result arrives.
def boom(*a, **k):
raise RuntimeError("simulated internal failure")
monkeypatch.setattr(c.shutil, "which", boom)
r = c.spawn_test({"command": "python3"}, timeout=0.3)
assert r["outcome"] == "error"
assert "simulated internal failure" in r["detail"]
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 8. Backup restore # 8. Backup restore
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -520,6 +562,42 @@ def test_config_mtime_returns_none_for_missing_file(tmp_path):
assert c.config_mtime(tmp_path / "nonexistent.json") is None 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): def test_external_change_summary_detects_non_server_key_change(tmp_path):
cfgpath = tmp_path / "cfg.json" cfgpath = tmp_path / "cfg.json"
original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}} original = {"numStartups": 1, "mcpServers": {"s": {"command": "node"}}}
@@ -638,3 +716,701 @@ def test_args_secret_warning_env_not_triggered():
def test_args_secret_warning_empty(): def test_args_secret_warning_empty():
assert c.args_secret_warning({}) is None assert c.args_secret_warning({}) is None
assert c.args_secret_warning({"args": []}) 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):
# 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
# --------------------------------------------------------------------------- #
# MSIX-virtualized Claude Desktop config detection (issue #7)
# --------------------------------------------------------------------------- #
def _make_msix_pkg(localappdata, pkg_name="AnthropicClaude_abc123xyz", with_config=True):
cfg_dir = localappdata / "Packages" / pkg_name / "LocalCache" / "Roaming" / "Claude"
cfg_dir.mkdir(parents=True)
cfg_path = cfg_dir / c.CONFIG_FILENAME
if with_config:
cfg_path.write_text('{"mcpServers": {}}')
return cfg_path
def test_msix_config_paths_finds_package_config(tmp_path):
local = tmp_path / "Local"
expected = _make_msix_pkg(local)
assert c.msix_config_paths(local) == [expected]
def test_msix_config_paths_ignores_non_claude_packages(tmp_path):
local = tmp_path / "Local"
(local / "Packages" / "SomeOtherApp_xyz" / "LocalCache" / "Roaming").mkdir(parents=True)
assert c.msix_config_paths(local) == []
def test_msix_config_paths_empty_when_package_dir_has_no_config_yet(tmp_path):
local = tmp_path / "Local"
_make_msix_pkg(local, with_config=False)
assert c.msix_config_paths(local) == []
def test_msix_config_paths_empty_when_no_packages_dir(tmp_path):
assert c.msix_config_paths(tmp_path / "Local") == []
def test_msix_config_paths_uses_localappdata_env_by_default(tmp_path, monkeypatch):
local = tmp_path / "Local"
expected = _make_msix_pkg(local)
monkeypatch.setenv("LOCALAPPDATA", str(local))
assert c.msix_config_paths() == [expected]
def test_detect_msix_claude_returns_none_on_non_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
local = tmp_path / "Local"
_make_msix_pkg(local)
assert c.detect_msix_claude(localappdata=local) is None
def test_detect_msix_claude_finds_virtualized_config_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
local = tmp_path / "Local"
expected = _make_msix_pkg(local)
assert c.detect_msix_claude(localappdata=local) == expected
def test_detect_msix_claude_none_when_nothing_present_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
local = tmp_path / "Local"
assert c.detect_msix_claude(localappdata=local) is None
def test_msix_warning_text_none_on_non_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
local = tmp_path / "Local"
_make_msix_pkg(local)
assert c.msix_warning_text(localappdata=local) is None
def test_msix_warning_text_none_when_nothing_detected(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
local = tmp_path / "Local"
assert c.msix_warning_text(localappdata=local) is None
def test_msix_warning_text_warns_when_virtualized_path_differs(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
roaming = tmp_path / "Roaming"
local = tmp_path / "Local"
real = _make_msix_pkg(local)
warning = c.msix_warning_text(appdata=roaming, localappdata=local)
assert warning is not None
assert "Microsoft Store" in warning
assert str(real) in warning
assert str(roaming / "Claude" / c.CONFIG_FILENAME) in warning
def test_msix_warning_text_none_when_plain_and_virtualized_paths_match(tmp_path, monkeypatch):
# Degenerate case: if the "plain" APPDATA base is pointed at the exact
# same tree the MSIX scan found (e.g. a symlink setup), there's nothing
# surprising to warn about.
monkeypatch.setattr(c.sys, "platform", "win32")
local = tmp_path / "Local"
pkg_claude_dir = local / "Packages" / "AnthropicClaude_abc123xyz" / "LocalCache" / "Roaming"
pkg_claude_dir.mkdir(parents=True)
(pkg_claude_dir / "Claude").mkdir()
(pkg_claude_dir / "Claude" / c.CONFIG_FILENAME).write_text("{}")
warning = c.msix_warning_text(appdata=pkg_claude_dir, localappdata=local)
assert warning is None
def test_discover_profiles_adds_msix_profile_on_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
base = tmp_path / "AppSupport"
base.mkdir()
monkeypatch.setattr(c, "app_support_base", lambda: base)
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
(tmp_path / "home" / ".claude").mkdir(parents=True)
local = tmp_path / "Local"
real = _make_msix_pkg(local)
monkeypatch.setenv("LOCALAPPDATA", str(local))
profs = c.discover_profiles()
msix = [p for p in profs if "MSIX" in p.label]
assert len(msix) == 1
assert msix[0].path == real
assert msix[0].config_exists
def test_discover_profiles_no_msix_profile_when_nothing_detected(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "win32")
base = tmp_path / "AppSupport"
base.mkdir()
monkeypatch.setattr(c, "app_support_base", lambda: base)
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
(tmp_path / "home" / ".claude").mkdir(parents=True)
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path / "Local"))
profs = c.discover_profiles()
assert not any("MSIX" in p.label for p in profs)
def test_discover_profiles_no_msix_profile_on_non_windows(tmp_path, monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
base = tmp_path / "AppSupport"
base.mkdir()
monkeypatch.setattr(c, "app_support_base", lambda: base)
monkeypatch.setattr(c.Path, "home", lambda: tmp_path / "home")
(tmp_path / "home" / ".claude").mkdir(parents=True)
local = tmp_path / "Local"
_make_msix_pkg(local)
monkeypatch.setenv("LOCALAPPDATA", str(local))
profs = c.discover_profiles()
assert not any("MSIX" in p.label for p in profs)
# --------------------------------------------------------------------------- #
# restart_claude_desktop / profile_targets_claude_desktop (issue #9)
# --------------------------------------------------------------------------- #
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", wait for exit (pgrep), then open -a Claude."""
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
if cmd[0] == "pgrep":
return _FakeCompletedProcess(returncode=1) # already exited
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(c.sys, "platform", "darwin")
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] in ("pkill", "pgrep"):
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/pgrep raising OSError (binary missing) must be swallowed, not
propagated -- relaunch is still attempted."""
def fake_run(cmd, **kwargs):
if cmd[0] in ("pkill", "pgrep"):
raise OSError("binary 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_macos_gives_up_if_app_wont_quit(monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
monkeypatch.setattr(c, "_macos_claude_running", lambda: True) # never exits
monkeypatch.setattr(c, "_MACOS_QUIT_WAIT_S", 0.2)
res = c.restart_claude_desktop()
assert res.success is False
assert "quit" in res.detail.lower()
def test_restart_claude_desktop_macos_waits_for_exit_then_relaunches(monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
monkeypatch.setattr(c, "_run_quiet", lambda cmd: None)
alive = iter([True, True, False]) # exits on the third poll
monkeypatch.setattr(c, "_macos_claude_running", lambda: next(alive))
launched = []
def fake_run(cmd, **kwargs):
launched.append(cmd)
return _FakeCompletedProcess(returncode=0)
monkeypatch.setattr(c.subprocess, "run", fake_run)
res = c.restart_claude_desktop()
assert res.success is True
assert launched == [["open", "-a", "Claude"]]
@pytest.fixture
def windows_shortcut(tmp_path, monkeypatch):
"""A fake %APPDATA% containing the Claude Start-menu shortcut."""
monkeypatch.setenv("APPDATA", str(tmp_path))
lnk = tmp_path / "Microsoft" / "Windows" / "Start Menu" / "Programs" / "Claude.lnk"
lnk.parent.mkdir(parents=True)
lnk.write_bytes(b"")
return lnk
def test_restart_claude_desktop_windows_commands(monkeypatch, windows_shortcut):
"""Windows: taskkill the process, then relaunch via the Start-menu shortcut."""
calls = []
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, windows_shortcut):
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_windows_missing_shortcut_refuses_before_killing(
monkeypatch, tmp_path
):
killed = []
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
monkeypatch.setattr(c.sys, "platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path)) # no Claude.lnk under here (MSIX case)
res = c.restart_claude_desktop()
assert res.success is False
assert "shortcut" in res.detail.lower()
assert killed == [] # Claude must NOT be killed when it can't be relaunched
def test_restart_supported_only_on_desktop_platforms(monkeypatch):
monkeypatch.setattr(c.sys, "platform", "darwin")
assert c.restart_supported()
monkeypatch.setattr(c.sys, "platform", "win32")
assert c.restart_supported()
monkeypatch.setattr(c.sys, "platform", "linux")
assert not c.restart_supported()
def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeypatch):
"""There is no Claude Desktop on Linux; 'pkill claude' would substring-match
running Claude Code CLI sessions. The restart must refuse outright."""
killed = []
monkeypatch.setattr(c, "_run_quiet", lambda cmd: killed.append(cmd))
monkeypatch.setattr(c.sys, "platform", "linux")
res = c.restart_claude_desktop()
assert res.success is False
assert killed == [] # nothing killed, nothing spawned
# --------------------------------------------------------------------------- #
# 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.2.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
# --------------------------------------------------------------------------- #
# Server search / filter (#27)
# --------------------------------------------------------------------------- #
def test_filter_matches_name():
e = c.ServerEntry("brave-search", {"command": "npx", "args": ["-y", "@x/brave"]}, True)
assert c.server_matches_filter(e, "brave")
def test_filter_matches_stdio_command():
e = c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True)
assert c.server_matches_filter(e, "uvx")
def test_filter_matches_remote_url():
e = c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True)
assert c.server_matches_filter(e, "example.com")
def test_filter_is_case_insensitive():
e = c.ServerEntry("BraveSearch", {"command": "NPX", "args": []}, True)
assert c.server_matches_filter(e, "bravesearch")
assert c.server_matches_filter(e, "npx")
def test_filter_empty_query_matches_everything():
e = c.ServerEntry("anything", {"command": "node", "args": []}, True)
assert c.server_matches_filter(e, "")
assert c.server_matches_filter(e, " ")
def test_filter_no_match_returns_false():
e = c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True)
assert not c.server_matches_filter(e, "nonexistent")
def test_filter_remote_query_does_not_match_stdio_command_field():
e = c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True)
assert not c.server_matches_filter(e, "npx")
def test_filter_servers_returns_only_matches():
servers = [
c.ServerEntry("brave-search", {"command": "npx", "args": []}, True),
c.ServerEntry("filesystem", {"command": "uvx", "args": []}, True),
c.ServerEntry("hosted", {"url": "https://mcp.example.com/sse"}, True),
]
assert [s.name for s in c.filter_servers(servers, "brave")] == ["brave-search"]
assert [s.name for s in c.filter_servers(servers, "")] == [s.name for s in servers]
assert c.filter_servers(servers, "zzz-nope") == []
# --------------------------------------------------------------------------- #
# Health status mapping (#28)
# --------------------------------------------------------------------------- #
def test_health_from_spawn_result_ok():
result = {
"outcome": "ok",
"returncode": None,
"stderr": "",
"detail": "still running after 3s — server started successfully",
}
status, summary = c.health_from_spawn_result(result)
assert status == c.HealthStatus.OK
assert "started" in summary
def test_health_from_spawn_result_not_applicable_is_untested():
result = {
"outcome": "not_applicable",
"returncode": None,
"stderr": "",
"detail": "remote server",
}
status, summary = c.health_from_spawn_result(result)
assert status == c.HealthStatus.UNTESTED
assert summary == "remote server"
def test_health_from_spawn_result_crashed_is_failed():
result = {
"outcome": "crashed",
"returncode": 1,
"stderr": "Traceback: boom\nsecond line",
"detail": "process exited with code 1",
}
status, summary = c.health_from_spawn_result(result)
assert status == c.HealthStatus.FAILED
assert "process exited with code 1" in summary
assert "Traceback: boom" in summary
def test_health_from_spawn_result_exited_is_failed():
result = {
"outcome": "exited",
"returncode": 0,
"stderr": "",
"detail": "process exited cleanly (code 0) — unusual",
}
status, summary = c.health_from_spawn_result(result)
assert status == c.HealthStatus.FAILED
assert "exited cleanly" in summary
def test_health_from_spawn_result_not_found_is_failed():
result = {
"outcome": "not_found",
"returncode": None,
"stderr": "",
"detail": "command not found: totallybogus",
}
status, summary = c.health_from_spawn_result(result)
assert status == c.HealthStatus.FAILED
assert "not found" in summary
def test_health_from_spawn_result_failed_without_stderr_has_no_dash():
result = {
"outcome": "not_found",
"returncode": None,
"stderr": "",
"detail": "command not found: x",
}
_, summary = c.health_from_spawn_result(result)
assert "" not in summary
# --------------------------------------------------------------------------- #
# App icon assets present (issue #20 — icons must exist to be bundled/loaded)
# --------------------------------------------------------------------------- #
def test_app_icon_assets_present():
from pathlib import Path
root = Path(c.__file__).resolve().parent
rounded = root / "icons" / "twin-gears" / "rounded"
# The window-icon builder in bcc.py loads these sizes; keep them present.
for size in (16, 32, 128, 256):
assert (rounded / f"icon-{size}.png").is_file(), f"missing icon-{size}.png"
assert (root / "icons" / "app.ico").is_file()
assert (root / "icons" / "app.icns").is_file()