Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f535fb77f | |||
| 8cf19d43c4 | |||
| 0ef4586698 | |||
| ed7c40cac9 | |||
| 1384ed9703 | |||
| 41891ddad4 | |||
| 408f517c5d | |||
| 9036729cd8 | |||
| 29a08e9532 | |||
| 87303809b8 | |||
| 42456f25d2 | |||
| 2d274b9e03 | |||
| 5c476bb13f | |||
| 62c8a2ea65 | |||
| b485357cd5 | |||
| 6a91f830dc | |||
| 6dacc26057 | |||
| 1087fc84d1 | |||
| 5df364fb2e | |||
| c7b2c90518 | |||
| c493aa0c84 | |||
| bb355dac31 | |||
| d95db2b026 | |||
| 42963f98b4 | |||
| f5c9780948 | |||
| 06326e5e9d | |||
| 6d91c709a7 | |||
| 3b5379a2b8 | |||
| f4d4301c26 | |||
| 5169b7276e | |||
| 668fb903d0 | |||
| 8c456c9a89 | |||
| 4c6fe7c5aa | |||
| 8c718387c0 | |||
| 15a30fb986 | |||
| c56dec8051 | |||
| 70b865be8f | |||
| 8fdcbda681 | |||
| 2d9fb083dc | |||
| 4ab3c3b00a | |||
| 3e07b51134 | |||
| a811e323e6 | |||
| 3cd18392c9 | |||
| 67c898cd35 | |||
| 16961a5cc8 | |||
| 2b3843a714 | |||
| 256827eaf3 | |||
| 85d47aea97 | |||
| f9752211a2 | |||
| 7c8fd6d0bb | |||
| f1935fe320 | |||
| 0ffc6a1fb6 | |||
| fd2c3567a0 | |||
| 346d0aabb6 | |||
| 5d59c1c423 | |||
| cffdee8a40 | |||
| 0843c51c7d | |||
| 82cec27c11 | |||
| 6c51bac1e0 | |||
| 4b45251682 | |||
| 2a0802b22f | |||
| fe66d53e9f | |||
| 8d90ab449d | |||
| 9760b1537e | |||
| 0f1cdbef3c | |||
| 165c65be5f | |||
| 3c65657d2f |
@@ -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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ a = Analysis(
|
|||||||
["bcc.py"],
|
["bcc.py"],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[],
|
datas=[("icons", "icons")],
|
||||||
hiddenimports=[],
|
hiddenimports=[],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
|
|||||||
info_plist={
|
info_plist={
|
||||||
"CFBundleName": "Better Claude Config",
|
"CFBundleName": "Better Claude Config",
|
||||||
"CFBundleDisplayName": "Better Claude Config",
|
"CFBundleDisplayName": "Better Claude Config",
|
||||||
"CFBundleShortVersionString": "1.0.0",
|
"CFBundleShortVersionString": "1.2.1",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.2.1",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+518
-4
@@ -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.1"
|
||||||
|
|
||||||
|
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,9 +1559,45 @@ 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
|
||||||
|
|
||||||
|
|
||||||
|
def _kill_process_tree_windows(pid: int) -> None:
|
||||||
|
"""
|
||||||
|
Kill `pid` and its whole descendant tree via `taskkill /T /F` (issue #13).
|
||||||
|
|
||||||
|
Popen.kill() only terminates the direct child; runner-style commands
|
||||||
|
(npx → node → server, cmd → real process) leave the actual server alive,
|
||||||
|
leaking a process on every Windows spawn test. taskkill walks the tree.
|
||||||
|
"""
|
||||||
|
flags = getattr(subprocess, "CREATE_NO_WINDOW", 0) # no console flash from the GUI exe
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
capture_output=True,
|
||||||
|
creationflags=flags,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
||||||
"""
|
"""
|
||||||
Attempt to start a stdio server and observe it for `timeout` seconds.
|
Attempt to start a stdio server and observe it for `timeout` seconds.
|
||||||
@@ -1283,12 +1608,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 +1639,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 +1663,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] = []
|
||||||
|
|
||||||
@@ -1347,6 +1692,10 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
)
|
)
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
popen_kwargs["start_new_session"] = True # own process group → clean kill
|
||||||
|
else:
|
||||||
|
# The packaged app is windowed (console=False); without this every
|
||||||
|
# spawn test of a console server flashes a console window.
|
||||||
|
popen_kwargs["creationflags"] = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen(args_list, **popen_kwargs)
|
proc = subprocess.Popen(args_list, **popen_kwargs)
|
||||||
@@ -1368,7 +1717,7 @@ def spawn_test(data: dict, timeout: float = 3.0) -> dict:
|
|||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
|
||||||
else:
|
else:
|
||||||
proc.kill() # best-effort on Windows
|
_kill_process_tree_windows(proc.pid)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||||
@@ -1400,6 +1749,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 +1855,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
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "better-claude-config"
|
name = "better-claude-config"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
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" }
|
||||||
|
|||||||
+833
-2
@@ -2,7 +2,12 @@
|
|||||||
proper test functions with tmp_path/monkeypatch fixtures)."""
|
proper test functions with tmp_path/monkeypatch fixtures)."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -224,8 +229,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 +331,91 @@ 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_windows_tree_kill_uses_taskkill(monkeypatch):
|
||||||
|
"""The Windows kill path must walk the process tree (taskkill /T /F);
|
||||||
|
Popen.kill() alone orphans grandchildren (issue #13)."""
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_run(cmd, **kwargs):
|
||||||
|
calls.append(cmd)
|
||||||
|
return _FakeRC()
|
||||||
|
|
||||||
|
class _FakeRC:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
monkeypatch.setattr(c.subprocess, "run", fake_run)
|
||||||
|
c._kill_process_tree_windows(1234)
|
||||||
|
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
|
||||||
|
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
|
||||||
|
"""A spawn-tested parent that has its own child (npx -> node style) must
|
||||||
|
not leave the child running after the timeout kill (issue #13)."""
|
||||||
|
import subprocess as sp
|
||||||
|
import time as _t
|
||||||
|
|
||||||
|
pid_file = tmp_path / "child.pid"
|
||||||
|
parent_script = (
|
||||||
|
"import subprocess, sys, time\n"
|
||||||
|
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
|
||||||
|
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
|
||||||
|
"time.sleep(60)\n"
|
||||||
|
)
|
||||||
|
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
|
||||||
|
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
|
||||||
|
assert pid_file.is_file(), "parent never spawned its child"
|
||||||
|
child_pid = int(pid_file.read_text())
|
||||||
|
|
||||||
|
deadline = _t.time() + 5.0
|
||||||
|
alive = True
|
||||||
|
while _t.time() < deadline:
|
||||||
|
out = sp.run(
|
||||||
|
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
|
||||||
|
).stdout
|
||||||
|
alive = str(child_pid) in out
|
||||||
|
if not alive:
|
||||||
|
break
|
||||||
|
_t.sleep(0.25)
|
||||||
|
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
|
||||||
|
|
||||||
|
|
||||||
|
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 +613,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 +767,705 @@ 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. Reads pyproject.toml rather than hard-coding a
|
||||||
|
# version here (which would make this a third place to bump).
|
||||||
|
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
|
||||||
|
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
|
||||||
|
assert m, "no [project] version found in pyproject.toml"
|
||||||
|
assert c.__version__ == m.group(1)
|
||||||
|
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user