Compare commits
80 Commits
v1.1.0
...
672d78f903
| Author | SHA1 | Date | |
|---|---|---|---|
| 672d78f903 | |||
| cd38fd0c78 | |||
| e6b60e94e7 | |||
| 4afe21666d | |||
| 06e74d4d2c | |||
| f92b851127 | |||
| 47c95ac006 | |||
| 6b22ad26f0 | |||
| 874948506c | |||
| ac2e73e9d7 | |||
| 82ff149373 | |||
| 31ef4a0e85 | |||
| 520b1b2ffd | |||
| 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,25 +29,42 @@ 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.
|
||||||
|
# cryptography is for tests/test_checksums.py (release signing helper).
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
run: pip install pytest
|
run: pip install pytest cryptography
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: python -m pytest -v
|
run: python -m pytest -v
|
||||||
|
|||||||
@@ -107,11 +107,72 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
# Needed for scripts/sign_checksums.py — the release job otherwise
|
||||||
|
# only downloads build artifacts, it doesn't check out the repo.
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v3
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
|
|
||||||
|
- name: Set up Python 3.12
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# download-artifact@v3 nests each artifact under a directory named
|
||||||
|
# after it (artifacts/<name>/<name>). Flatten into one directory so
|
||||||
|
# SHA256SUMS lists plain filenames, matching what `sha256sum -c`
|
||||||
|
# expects when run from inside an extracted release download.
|
||||||
|
- name: Collect release files
|
||||||
|
run: |
|
||||||
|
mkdir -p release-files
|
||||||
|
find artifacts -type f -exec cp {} release-files/ \;
|
||||||
|
ls -la release-files
|
||||||
|
|
||||||
|
- name: Generate SHA256SUMS
|
||||||
|
run: python3 scripts/sign_checksums.py generate release-files --out release-files/SHA256SUMS
|
||||||
|
|
||||||
|
# ── Sign the checksum manifest (best-effort) ──────────────────────
|
||||||
|
#
|
||||||
|
# BCC binaries are not code-signed (no budget for a paid cert). This
|
||||||
|
# is the free half: a checksum manifest, detached-signed with
|
||||||
|
# Ed25519, so a tampered download is detectable by anyone who
|
||||||
|
# checks. It does NOT remove Gatekeeper/SmartScreen warnings.
|
||||||
|
#
|
||||||
|
# The private key is a repo secret (RELEASE_SIGNING_KEY, base64 raw
|
||||||
|
# Ed25519 seed) generated via the Catalog Console (#62). If it's not
|
||||||
|
# set, we still publish the release — just without a .sig — rather
|
||||||
|
# than fail the release outright.
|
||||||
|
- name: Check for signing key
|
||||||
|
id: signing
|
||||||
|
run: |
|
||||||
|
if [ -n "${{ secrets.RELEASE_SIGNING_KEY }}" ]; then
|
||||||
|
echo "has_key=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "has_key=false" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Install signing dependencies
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
run: pip install cryptography
|
||||||
|
|
||||||
|
- name: Sign SHA256SUMS
|
||||||
|
if: steps.signing.outputs.has_key == 'true'
|
||||||
|
env:
|
||||||
|
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
python3 scripts/sign_checksums.py sign \
|
||||||
|
--sums release-files/SHA256SUMS \
|
||||||
|
--out release-files/SHA256SUMS.sig
|
||||||
|
|
||||||
|
- name: Warn — release will be unsigned
|
||||||
|
if: steps.signing.outputs.has_key != 'true'
|
||||||
|
run: |
|
||||||
|
echo "::warning::RELEASE_SIGNING_KEY secret is not set — this release is being published WITHOUT a signed SHA256SUMS.sig. Add the secret (base64 raw Ed25519 seed, generated via the Catalog Console, #62) before the next tag."
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
@@ -119,7 +180,9 @@ jobs:
|
|||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
generate_release_notes: false
|
generate_release_notes: false
|
||||||
files: artifacts/**/*
|
files: |
|
||||||
|
artifacts/**/*
|
||||||
|
release-files/SHA256SUMS*
|
||||||
body: |
|
body: |
|
||||||
## Better Claude Config ${{ github.ref_name }}
|
## Better Claude Config ${{ github.ref_name }}
|
||||||
|
|
||||||
@@ -139,5 +202,8 @@ jobs:
|
|||||||
xattr -cr /Applications/BetterClaudeConfig.app
|
xattr -cr /Applications/BetterClaudeConfig.app
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Verifying your download
|
||||||
|
Every release includes `SHA256SUMS` (and, when the signing key is configured, a detached `SHA256SUMS.sig`). See [Verifying your download](https://git.avezzano.io/the_og/better-claude-config#verifying-your-download) in the README for commands. This proves you got the file we published — it does not remove Gatekeeper/SmartScreen warnings.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
No Python installation needed — the app is self-contained.
|
No Python installation needed — the app is self-contained.
|
||||||
|
|||||||
@@ -19,6 +19,65 @@ Pre-built self-contained binaries are attached to every [GitHub Release](../../r
|
|||||||
|
|
||||||
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal.
|
||||||
|
|
||||||
|
## Verifying your download
|
||||||
|
|
||||||
|
BCC isn't code-signed — there's no budget for a paid certificate (macOS
|
||||||
|
Developer ID, Windows Authenticode). Instead, every release publishes a
|
||||||
|
`SHA256SUMS` file listing the checksum of each archive, detached-signed with
|
||||||
|
Ed25519 as `SHA256SUMS.sig`. Both are attached to the release alongside the
|
||||||
|
binaries.
|
||||||
|
|
||||||
|
**What this proves:** the file you downloaded is byte-for-byte what we
|
||||||
|
published, and the manifest itself was signed by our release key.
|
||||||
|
|
||||||
|
**What this does NOT do:** it does not make the binary "safe," and it does
|
||||||
|
**not** remove the macOS Gatekeeper or Windows SmartScreen warning — those
|
||||||
|
are only suppressed by a paid OS-vendor certificate, which this project
|
||||||
|
doesn't have. Verifying checksums is about detecting tampering in transit or
|
||||||
|
on a mirror, not about vouching for the software.
|
||||||
|
|
||||||
|
**Release signing public key** (Ed25519, base64, raw 32 bytes):
|
||||||
|
|
||||||
|
```
|
||||||
|
<PLACEHOLDER — AJ: paste the public key from the Catalog Console (#62) here>
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS / Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From inside the folder you downloaded the release files into:
|
||||||
|
sha256sum -c SHA256SUMS
|
||||||
|
```
|
||||||
|
|
||||||
|
If your `sha256sum` complains about missing files, download `SHA256SUMS`
|
||||||
|
into the same directory as the archive you downloaded — it lists every
|
||||||
|
platform's archive, and only the one(s) present will be checked.
|
||||||
|
|
||||||
|
To also verify the manifest's signature (optional, requires Python +
|
||||||
|
`pip install cryptography` and a checkout of this repo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 scripts/sign_checksums.py verify \
|
||||||
|
--sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 "<the public key above>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Get-FileHash .\BetterClaudeConfig-Windows.zip -Algorithm SHA256
|
||||||
|
```
|
||||||
|
|
||||||
|
Compare the printed hash (case-insensitively) against the matching line in
|
||||||
|
`SHA256SUMS`.
|
||||||
|
|
||||||
|
### If a release has no `SHA256SUMS.sig`
|
||||||
|
|
||||||
|
The signing key is a repo secret that has to be configured manually; if a
|
||||||
|
release is missing the `.sig` file, the checksums themselves are still
|
||||||
|
valid and safe to check against — the release workflow only skips signing,
|
||||||
|
never checksum generation.
|
||||||
|
|
||||||
## Run from source
|
## Run from source
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -92,6 +151,7 @@ file is also listed, marked *legacy*, so you can copy them over.
|
|||||||
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
- `test_core.py` — unit suite for the core (`python test_core.py`).
|
||||||
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
- `bcc.spec` — PyInstaller build spec (cross-platform).
|
||||||
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs.
|
||||||
|
- `scripts/sign_checksums.py` — generates and Ed25519-signs the release `SHA256SUMS` manifest (see [Verifying your download](#verifying-your-download)).
|
||||||
|
|
||||||
## Building from source
|
## Building from source
|
||||||
|
|
||||||
|
|||||||
@@ -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.3.0",
|
||||||
"CFBundleVersion": "1.0.0",
|
"CFBundleVersion": "1.3.0",
|
||||||
"NSHighResolutionCapable": True,
|
"NSHighResolutionCapable": True,
|
||||||
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
"NSRequiresAquaSystemAppearance": False, # supports dark mode
|
||||||
"LSMinimumSystemVersion": "11.0",
|
"LSMinimumSystemVersion": "11.0",
|
||||||
|
|||||||
+689
-5
@@ -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"
|
||||||
@@ -38,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json"
|
|||||||
# we can toggle it back on without losing the definition.
|
# we can toggle it back on without losing the definition.
|
||||||
DISABLED_KEY = "_disabledMcpServers"
|
DISABLED_KEY = "_disabledMcpServers"
|
||||||
|
|
||||||
|
# Named server sets: {set_name: [enabled server names]}. Same pattern as
|
||||||
|
# DISABLED_KEY — a bcc-owned key Claude ignores, stored in the config file so
|
||||||
|
# sets travel with it. Applying a set enables exactly the listed servers and
|
||||||
|
# parks the rest under DISABLED_KEY.
|
||||||
|
SETS_KEY = "_bccServerSets"
|
||||||
|
|
||||||
BACKUP_DIRNAME = ".bcc_backups"
|
BACKUP_DIRNAME = ".bcc_backups"
|
||||||
MAX_BACKUPS = 15
|
MAX_BACKUPS = 15
|
||||||
|
|
||||||
@@ -46,6 +53,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.3.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,17 +197,150 @@ 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_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
|
||||||
|
"""
|
||||||
|
Find project-scope `.mcp.json` configs known to Claude Code.
|
||||||
|
|
||||||
|
`~/.claude.json` keeps a `projects` map keyed by absolute project
|
||||||
|
directory path (that's what the CLI writes as it's used in each repo).
|
||||||
|
Any project whose directory has a `.mcp.json` file next to it -- a
|
||||||
|
standalone file with a top-level `mcpServers` object, same shape BCC
|
||||||
|
already edits -- is surfaced here as its own profile so it can be opened
|
||||||
|
via 'Add config...' without hunting for the path by hand.
|
||||||
|
|
||||||
|
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a
|
||||||
|
`projects` value that isn't a dict, just yields an empty list rather than
|
||||||
|
raising -- this is best-effort discovery, not a required config load.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cfg = load_config(claude_json_path)
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
projects = cfg.get("projects")
|
||||||
|
if not isinstance(projects, dict):
|
||||||
|
return []
|
||||||
|
out: list[Profile] = []
|
||||||
|
for key in sorted(k for k in projects if isinstance(k, str)):
|
||||||
|
mcp_path = Path(key) / ".mcp.json"
|
||||||
|
if mcp_path.is_file():
|
||||||
|
out.append(
|
||||||
|
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
(Claude Desktop installs), then also check for a Claude Code global config.
|
(Claude Desktop installs), then also check for a Claude Code global config
|
||||||
|
and any project-scope `.mcp.json` configs it knows about.
|
||||||
|
|
||||||
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
|
||||||
for permissions/hooks and rejects an mcpServers key with a schema error.
|
for permissions/hooks and rejects an mcpServers key with a schema error.
|
||||||
|
Project scope: ~/.claude.json also tracks a `projects` map, one entry per
|
||||||
|
directory Claude Code has been run in; any of those with a `.mcp.json`
|
||||||
|
file are surfaced as their own profiles too (see
|
||||||
|
`discover_project_configs`).
|
||||||
"""
|
"""
|
||||||
base = app_support_base()
|
base = app_support_base()
|
||||||
out: list[Profile] = []
|
out: list[Profile] = []
|
||||||
@@ -106,10 +352,22 @@ 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()))
|
||||||
|
|
||||||
|
existing_paths = {str(p.path) for p in out}
|
||||||
|
for proj in discover_project_configs(cc_cfg):
|
||||||
|
if str(proj.path) not in existing_paths:
|
||||||
|
existing_paths.add(str(proj.path))
|
||||||
|
out.append(proj)
|
||||||
|
|
||||||
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
# Legacy: earlier BCC versions (and hand-edits) may have parked servers in
|
||||||
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
|
||||||
# file only when it actually contains an mcpServers block, so the user can
|
# file only when it actually contains an mcpServers block, so the user can
|
||||||
@@ -135,6 +393,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 +458,89 @@ def extract_servers(cfg: dict) -> list[ServerEntry]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Named server sets (issue #52)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def list_server_sets(cfg: dict) -> dict[str, list[str]]:
|
||||||
|
"""
|
||||||
|
Return {set_name: [enabled server names]} from cfg's SETS_KEY.
|
||||||
|
|
||||||
|
Fail-soft: entries whose value isn't a list of strings (hand-edited or
|
||||||
|
corrupted) are skipped rather than raising, so one bad set never hides
|
||||||
|
the rest.
|
||||||
|
"""
|
||||||
|
raw = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(raw, dict):
|
||||||
|
return {}
|
||||||
|
out: dict[str, list[str]] = {}
|
||||||
|
for name, members in raw.items():
|
||||||
|
if isinstance(members, list) and all(isinstance(m, str) for m in members):
|
||||||
|
out[str(name)] = list(members)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def save_server_set(cfg: dict, name: str, servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Snapshot the current enabled-server names into cfg under SETS_KEY as
|
||||||
|
`name` (overwriting an existing set of that name). Returns the saved
|
||||||
|
member list. The caller decides when cfg reaches disk (normal Save flow).
|
||||||
|
"""
|
||||||
|
members = [s.name for s in servers if s.enabled]
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict):
|
||||||
|
sets = {}
|
||||||
|
cfg[SETS_KEY] = sets
|
||||||
|
sets[name] = members
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def delete_server_set(cfg: dict, name: str) -> bool:
|
||||||
|
"""Remove set `name` from cfg. Drops SETS_KEY entirely when the last set
|
||||||
|
goes, so untouched configs don't grow an empty bcc key. Returns True if
|
||||||
|
something was deleted."""
|
||||||
|
sets = cfg.get(SETS_KEY)
|
||||||
|
if not isinstance(sets, dict) or name not in sets:
|
||||||
|
return False
|
||||||
|
del sets[name]
|
||||||
|
if not sets:
|
||||||
|
cfg.pop(SETS_KEY, None)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def apply_server_set(servers: list[ServerEntry], enabled_names: list[str]) -> list[str]:
|
||||||
|
"""
|
||||||
|
Enable exactly the servers named in `enabled_names`; disable every other
|
||||||
|
entry (in place). Returns the set members that no longer exist in
|
||||||
|
`servers` — the caller surfaces those as a warning, and the rest of the
|
||||||
|
set still applies.
|
||||||
|
"""
|
||||||
|
wanted = set(enabled_names)
|
||||||
|
present: set[str] = set()
|
||||||
|
for s in servers:
|
||||||
|
s.enabled = s.name in wanted
|
||||||
|
present.add(s.name)
|
||||||
|
return sorted(wanted - present)
|
||||||
|
|
||||||
|
|
||||||
|
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 +732,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 +850,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 +1326,89 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
|
|||||||
return problems
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def lint_server(name: str, data: dict) -> list[str]:
|
||||||
|
"""Return non-blocking structural warnings for a single server definition.
|
||||||
|
|
||||||
|
Unlike validate_servers, nothing here blocks Save -- these are advisory
|
||||||
|
notes about shapes that will round-trip through JSON fine but are
|
||||||
|
probably not what the user intended (args given as a plain string
|
||||||
|
instead of a list, an env value that isn't a string, an unrecognized
|
||||||
|
`type`, unknown top-level fields, etc.).
|
||||||
|
"""
|
||||||
|
nm = name.strip() or "(unnamed)"
|
||||||
|
warnings: list[str] = []
|
||||||
|
|
||||||
|
if "command" in data and not isinstance(data["command"], str):
|
||||||
|
warnings.append(f"'{nm}': 'command' should be a string")
|
||||||
|
|
||||||
|
if "args" in data:
|
||||||
|
args = data["args"]
|
||||||
|
if not isinstance(args, list):
|
||||||
|
warnings.append(f"'{nm}': 'args' should be a list (one argument per item)")
|
||||||
|
elif any(not isinstance(a, str) for a in args):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': 'args' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("env", "headers"):
|
||||||
|
if field not in data:
|
||||||
|
continue
|
||||||
|
val = data[field]
|
||||||
|
if not isinstance(val, dict):
|
||||||
|
warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs")
|
||||||
|
elif any(not isinstance(v, str) for v in val.values()):
|
||||||
|
warnings.append(
|
||||||
|
f"'{nm}': '{field}' contains non-string values "
|
||||||
|
"(they will be saved as-is; Claude expects strings)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "type" in data:
|
||||||
|
t = data["type"]
|
||||||
|
if t not in ("http", "sse", "stdio"):
|
||||||
|
warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})")
|
||||||
|
|
||||||
|
extra = sorted(k for k in data if k not in KNOWN_FIELDS)
|
||||||
|
if extra:
|
||||||
|
warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}")
|
||||||
|
|
||||||
|
return warnings
|
||||||
|
|
||||||
|
|
||||||
|
def lint_servers(servers: list[ServerEntry]) -> list[str]:
|
||||||
|
"""Concatenate lint_server warnings across every entry, in order."""
|
||||||
|
out: list[str] = []
|
||||||
|
for s in servers:
|
||||||
|
out.extend(lint_server(s.name, s.data))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 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 +1729,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 +1778,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 +1809,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 +1833,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 +1862,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 +1887,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 +1919,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 +2025,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.3.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" }
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
|
|||||||
# Test / lint
|
# Test / lint
|
||||||
pytest>=8.0
|
pytest>=8.0
|
||||||
ruff>=0.6
|
ruff>=0.6
|
||||||
|
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
|
||||||
|
|||||||
Executable
+235
@@ -0,0 +1,235 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate a SHA256SUMS file for release artifacts and sign it with Ed25519.
|
||||||
|
|
||||||
|
BCC ships PyInstaller binaries that are not code-signed (no budget for a
|
||||||
|
macOS Developer ID / Windows Authenticode certificate). This script provides
|
||||||
|
the free half of supply-chain integrity: a checksum manifest, detached-signed
|
||||||
|
so downloaders can verify the file they got is the file we published.
|
||||||
|
|
||||||
|
This does NOT remove Gatekeeper/SmartScreen warnings and does NOT prove the
|
||||||
|
binary is safe to run -- only that it matches what the release signing key
|
||||||
|
attested to.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Hash every file in a directory into a SHA256SUMS-format manifest.
|
||||||
|
python scripts/sign_checksums.py generate <dir> --out SHA256SUMS
|
||||||
|
|
||||||
|
# Sign a manifest, producing a detached signature.
|
||||||
|
# Private key comes from $RELEASE_SIGNING_KEY (base64 raw Ed25519 seed)
|
||||||
|
# unless --key-b64 is given explicitly (mostly for tests).
|
||||||
|
python scripts/sign_checksums.py sign --sums SHA256SUMS --out SHA256SUMS.sig
|
||||||
|
|
||||||
|
# Verify a manifest against a detached signature and a public key.
|
||||||
|
python scripts/sign_checksums.py verify --sums SHA256SUMS --sig SHA256SUMS.sig \
|
||||||
|
--pubkey-b64 <base64 raw Ed25519 public key>
|
||||||
|
|
||||||
|
The private key is generated and rotated via the Catalog Console (#62) --
|
||||||
|
this script never generates or stores a key itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Domain separation prefix: ties every signature to "a BCC release checksum
|
||||||
|
# manifest" so a signature can never be replayed against an unrelated
|
||||||
|
# message signed by the same key.
|
||||||
|
DOMAIN_PREFIX = b"bcc-release-v1|"
|
||||||
|
|
||||||
|
CHUNK_SIZE = 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_file(path: Path) -> str:
|
||||||
|
"""Return the lowercase hex SHA-256 digest of a file's contents."""
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while chunk := fh.read(CHUNK_SIZE):
|
||||||
|
digest.update(chunk)
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def build_checksums_text(files: dict[str, str]) -> str:
|
||||||
|
"""Build a sha256sum(1)-compatible manifest body.
|
||||||
|
|
||||||
|
`files` maps filename -> hex digest. Entries are sorted by filename for
|
||||||
|
a deterministic, diffable output. Format matches `sha256sum` exactly:
|
||||||
|
"<hash> <filename>\n" (two spaces, no path components).
|
||||||
|
"""
|
||||||
|
lines = [f"{digest} {name}" for name, digest in sorted(files.items())]
|
||||||
|
body = "\n".join(lines)
|
||||||
|
return body + "\n" if body else ""
|
||||||
|
|
||||||
|
|
||||||
|
def generate_checksums(directory: Path, *, exclude: set[str] | None = None) -> str:
|
||||||
|
"""Hash every regular file directly inside `directory` (non-recursive)
|
||||||
|
and return the SHA256SUMS text. Filenames are recorded without any
|
||||||
|
directory prefix so the manifest can be verified from inside the
|
||||||
|
directory it describes.
|
||||||
|
"""
|
||||||
|
exclude = exclude or set()
|
||||||
|
files: dict[str, str] = {}
|
||||||
|
for entry in sorted(directory.iterdir()):
|
||||||
|
if not entry.is_file():
|
||||||
|
continue
|
||||||
|
if entry.name in exclude:
|
||||||
|
continue
|
||||||
|
files[entry.name] = sha256_file(entry)
|
||||||
|
return build_checksums_text(files)
|
||||||
|
|
||||||
|
|
||||||
|
def _signing_message(sums_text: str) -> bytes:
|
||||||
|
"""The exact bytes that get signed: the domain prefix followed by the
|
||||||
|
raw bytes of the SHA256SUMS file content."""
|
||||||
|
return DOMAIN_PREFIX + sums_text.encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def sign_checksums(seed_b64: str, sums_text: str) -> bytes:
|
||||||
|
"""Sign `sums_text` with the Ed25519 private key encoded (base64, raw
|
||||||
|
32-byte seed) in `seed_b64`. Returns the raw 64-byte signature."""
|
||||||
|
# Imported lazily so `generate` mode (used on every CI run) never
|
||||||
|
# requires the `cryptography` package to be installed.
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
if len(seed) != 32:
|
||||||
|
raise ValueError(f"expected a 32-byte raw Ed25519 seed, got {len(seed)} bytes")
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
return private_key.sign(_signing_message(sums_text))
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checksums(pubkey_b64: str, sums_text: str, signature: bytes) -> bool:
|
||||||
|
"""Verify `signature` over `sums_text` against the base64-encoded raw
|
||||||
|
32-byte Ed25519 public key. Returns True/False; never raises for a bad
|
||||||
|
signature (only for malformed inputs)."""
|
||||||
|
from cryptography.exceptions import InvalidSignature
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||||
|
|
||||||
|
pubkey_bytes = base64.b64decode(pubkey_b64)
|
||||||
|
if len(pubkey_bytes) != 32:
|
||||||
|
raise ValueError(
|
||||||
|
f"expected a 32-byte raw Ed25519 public key, got {len(pubkey_bytes)} bytes"
|
||||||
|
)
|
||||||
|
public_key = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
|
||||||
|
try:
|
||||||
|
public_key.verify(signature, _signing_message(sums_text))
|
||||||
|
return True
|
||||||
|
except InvalidSignature:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_b64_from_seed(seed_b64: str) -> str:
|
||||||
|
"""Derive the base64 raw public key from a base64 raw seed. Handy for
|
||||||
|
local key-pair sanity checks; not used by the release workflow."""
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
private_key = Ed25519PrivateKey.from_private_bytes(seed)
|
||||||
|
raw = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(raw).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _cmd_generate(args: argparse.Namespace) -> int:
|
||||||
|
directory = Path(args.directory)
|
||||||
|
exclude = {"SHA256SUMS", "SHA256SUMS.sig"}
|
||||||
|
text = generate_checksums(directory, exclude=exclude)
|
||||||
|
out_path = Path(args.out)
|
||||||
|
out_path.write_text(text, encoding="utf-8")
|
||||||
|
print(f"Wrote {out_path} ({len(text.splitlines())} entries)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_sign(args: argparse.Namespace) -> int:
|
||||||
|
seed_b64 = args.key_b64 or os.environ.get(args.key_env, "")
|
||||||
|
if not seed_b64:
|
||||||
|
print(
|
||||||
|
f"error: no signing key provided (checked --key-b64 and ${args.key_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = sign_checksums(seed_b64, sums_text)
|
||||||
|
Path(args.out).write_bytes(signature)
|
||||||
|
print(f"Wrote {args.out} ({len(signature)} bytes)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_verify(args: argparse.Namespace) -> int:
|
||||||
|
pubkey_b64 = args.pubkey_b64 or os.environ.get(args.pubkey_env, "")
|
||||||
|
if not pubkey_b64:
|
||||||
|
print(
|
||||||
|
f"error: no public key provided (checked --pubkey-b64 and ${args.pubkey_env})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
sums_text = Path(args.sums).read_text(encoding="utf-8")
|
||||||
|
signature = Path(args.sig).read_bytes()
|
||||||
|
ok = verify_checksums(pubkey_b64, sums_text, signature)
|
||||||
|
if ok:
|
||||||
|
print("OK: signature is valid")
|
||||||
|
return 0
|
||||||
|
print("FAILED: signature is invalid", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="mode", required=True)
|
||||||
|
|
||||||
|
p_gen = sub.add_parser(
|
||||||
|
"generate", help="hash every file in a directory into a SHA256SUMS manifest"
|
||||||
|
)
|
||||||
|
p_gen.add_argument("directory", help="directory whose files should be hashed (non-recursive)")
|
||||||
|
p_gen.add_argument("--out", required=True, help="path to write the SHA256SUMS manifest to")
|
||||||
|
p_gen.set_defaults(func=_cmd_generate)
|
||||||
|
|
||||||
|
p_sign = sub.add_parser("sign", help="detached-sign a SHA256SUMS manifest with Ed25519")
|
||||||
|
p_sign.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest to sign")
|
||||||
|
p_sign.add_argument("--out", required=True, help="path to write the detached signature to")
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-b64", default=None, help="base64 raw Ed25519 seed (overrides --key-env)"
|
||||||
|
)
|
||||||
|
p_sign.add_argument(
|
||||||
|
"--key-env",
|
||||||
|
default="RELEASE_SIGNING_KEY",
|
||||||
|
help="environment variable holding the base64 seed (default: RELEASE_SIGNING_KEY)",
|
||||||
|
)
|
||||||
|
p_sign.set_defaults(func=_cmd_sign)
|
||||||
|
|
||||||
|
p_verify = sub.add_parser(
|
||||||
|
"verify", help="verify a SHA256SUMS manifest against a detached signature"
|
||||||
|
)
|
||||||
|
p_verify.add_argument("--sums", required=True, help="path to the SHA256SUMS manifest")
|
||||||
|
p_verify.add_argument("--sig", required=True, help="path to the detached signature")
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-b64", default=None, help="base64 raw Ed25519 public key (overrides --pubkey-env)"
|
||||||
|
)
|
||||||
|
p_verify.add_argument(
|
||||||
|
"--pubkey-env",
|
||||||
|
default="RELEASE_SIGNING_PUBKEY",
|
||||||
|
help="environment variable holding the base64 public key (default: RELEASE_SIGNING_PUBKEY)",
|
||||||
|
)
|
||||||
|
p_verify.set_defaults(func=_cmd_verify)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
"""Tests for scripts/sign_checksums.py: SHA256SUMS generation and detached
|
||||||
|
Ed25519 signing/verification for release artifacts."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
|
||||||
|
import sign_checksums as sc
|
||||||
|
|
||||||
|
cryptography = pytest.importorskip("cryptography")
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey # noqa: E402
|
||||||
|
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _make_keypair() -> tuple[str, str]:
|
||||||
|
"""Return (seed_b64, pubkey_b64) for a fresh Ed25519 keypair."""
|
||||||
|
private_key = Ed25519PrivateKey.generate()
|
||||||
|
seed = private_key.private_bytes_raw()
|
||||||
|
pubkey = private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
|
||||||
|
return base64.b64encode(seed).decode("ascii"), base64.b64encode(pubkey).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sha256_file / build_checksums_text / generate_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sha256_file_matches_hashlib(tmp_path):
|
||||||
|
f = tmp_path / "a.txt"
|
||||||
|
f.write_bytes(b"hello world")
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
assert sc.sha256_file(f) == hashlib.sha256(b"hello world").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_sorted_and_formatted():
|
||||||
|
files = {"zeta.zip": "aa" * 32, "alpha.zip": "bb" * 32}
|
||||||
|
text = sc.build_checksums_text(files)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert lines[0].endswith("alpha.zip")
|
||||||
|
assert lines[1].endswith("zeta.zip")
|
||||||
|
# Standard sha256sum format: hash, two spaces, filename.
|
||||||
|
assert lines[0] == f"{'bb' * 32} alpha.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_checksums_text_empty():
|
||||||
|
assert sc.build_checksums_text({}) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_from_directory(tmp_path):
|
||||||
|
(tmp_path / "b.bin").write_bytes(b"second")
|
||||||
|
(tmp_path / "a.bin").write_bytes(b"first")
|
||||||
|
(tmp_path / "subdir").mkdir()
|
||||||
|
(tmp_path / "subdir" / "ignored.bin").write_bytes(b"nested, not hashed")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path)
|
||||||
|
lines = text.splitlines()
|
||||||
|
assert len(lines) == 2
|
||||||
|
assert lines[0].endswith("a.bin")
|
||||||
|
assert lines[1].endswith("b.bin")
|
||||||
|
assert "subdir" not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_checksums_excludes_manifest_files(tmp_path):
|
||||||
|
(tmp_path / "archive.zip").write_bytes(b"payload")
|
||||||
|
(tmp_path / "SHA256SUMS").write_text("stale")
|
||||||
|
(tmp_path / "SHA256SUMS.sig").write_bytes(b"stale-sig")
|
||||||
|
|
||||||
|
text = sc.generate_checksums(tmp_path, exclude={"SHA256SUMS", "SHA256SUMS.sig"})
|
||||||
|
assert "archive.zip" in text
|
||||||
|
assert "SHA256SUMS" not in text.replace("archive.zip", "")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# sign_checksums / verify_checksums
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_sign_then_verify_roundtrip():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "deadbeef" * 8 + " BetterClaudeConfig-Linux.tar.gz\n"
|
||||||
|
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert len(signature) == 64
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_tampered_checksums():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "aa" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
tampered = "bb" * 32 + " file.zip\n"
|
||||||
|
assert sc.verify_checksums(pubkey_b64, tampered, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_rejects_wrong_key():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
_, other_pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "cc" * 32 + " file.zip\n"
|
||||||
|
signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
|
||||||
|
assert sc.verify_checksums(other_pubkey_b64, sums_text, signature) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_domain_prefix_is_applied():
|
||||||
|
"""The signed message must be prefixed, not the raw manifest bytes --
|
||||||
|
otherwise a signature over this manifest could be replayed as a
|
||||||
|
signature over an unrelated message with the same bytes elsewhere."""
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_text = "11" * 32 + " file.zip\n"
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey as PK
|
||||||
|
|
||||||
|
seed = base64.b64decode(seed_b64)
|
||||||
|
raw_signature = PK.from_private_bytes(seed).sign(sums_text.encode("utf-8"))
|
||||||
|
|
||||||
|
# A signature over the raw (unprefixed) bytes must NOT verify via our
|
||||||
|
# domain-separated verify function.
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, raw_signature) is False
|
||||||
|
|
||||||
|
# But our own sign_checksums() output does verify.
|
||||||
|
good_signature = sc.sign_checksums(seed_b64, sums_text)
|
||||||
|
assert sc.verify_checksums(pubkey_b64, sums_text, good_signature) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_sign_checksums_rejects_bad_seed_length():
|
||||||
|
bad_seed_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.sign_checksums(bad_seed_b64, "irrelevant\n")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checksums_rejects_bad_pubkey_length():
|
||||||
|
seed_b64, _ = _make_keypair()
|
||||||
|
sig = sc.sign_checksums(seed_b64, "irrelevant\n")
|
||||||
|
bad_pubkey_b64 = base64.b64encode(b"too-short").decode("ascii")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
sc.verify_checksums(bad_pubkey_b64, "irrelevant\n", sig)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_key_b64_from_seed_matches_generated_pubkey():
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
assert sc.public_key_b64_from_seed(seed_b64) == pubkey_b64
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# CLI (end-to-end, via subprocess so argparse wiring is exercised too)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "sign_checksums.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _run(*args, env=None):
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, str(SCRIPT), *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_generate_sign_verify_roundtrip(tmp_path, monkeypatch):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
|
||||||
|
release_dir = tmp_path / "release-files"
|
||||||
|
release_dir.mkdir()
|
||||||
|
(release_dir / "BetterClaudeConfig-Linux.tar.gz").write_bytes(b"fake archive contents")
|
||||||
|
(release_dir / "BetterClaudeConfig-macOS.zip").write_bytes(b"fake zip contents")
|
||||||
|
|
||||||
|
sums_path = release_dir / "SHA256SUMS"
|
||||||
|
sig_path = release_dir / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
gen = _run("generate", str(release_dir), "--out", str(sums_path))
|
||||||
|
assert gen.returncode == 0, gen.stderr
|
||||||
|
assert sums_path.exists()
|
||||||
|
body = sums_path.read_text()
|
||||||
|
assert "BetterClaudeConfig-Linux.tar.gz" in body
|
||||||
|
assert "BetterClaudeConfig-macOS.zip" in body
|
||||||
|
|
||||||
|
sign = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
assert sign.returncode == 0, sign.stderr
|
||||||
|
assert sig_path.exists()
|
||||||
|
assert sig_path.stat().st_size == 64
|
||||||
|
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode == 0, verify.stderr
|
||||||
|
assert "OK" in verify.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sign_without_key_fails_loudly(tmp_path):
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
env = {k: v for k, v in os.environ.items() if k != "RELEASE_SIGNING_KEY"}
|
||||||
|
result = _run("sign", "--sums", str(sums_path), "--out", str(sig_path), env=env)
|
||||||
|
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert not sig_path.exists(), "must never write a bogus/empty signature file"
|
||||||
|
assert "no signing key" in result.stderr.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_verify_detects_tampering(tmp_path):
|
||||||
|
seed_b64, pubkey_b64 = _make_keypair()
|
||||||
|
sums_path = tmp_path / "SHA256SUMS"
|
||||||
|
sums_path.write_text("aa" * 32 + " file.zip\n")
|
||||||
|
sig_path = tmp_path / "SHA256SUMS.sig"
|
||||||
|
|
||||||
|
_run("sign", "--sums", str(sums_path), "--out", str(sig_path), "--key-b64", seed_b64)
|
||||||
|
|
||||||
|
sums_path.write_text("bb" * 32 + " file.zip\n") # tamper after signing
|
||||||
|
verify = _run(
|
||||||
|
"verify",
|
||||||
|
"--sums",
|
||||||
|
str(sums_path),
|
||||||
|
"--sig",
|
||||||
|
str(sig_path),
|
||||||
|
"--pubkey-b64",
|
||||||
|
pubkey_b64,
|
||||||
|
)
|
||||||
|
assert verify.returncode != 0
|
||||||
|
assert "FAILED" in verify.stdout + verify.stderr
|
||||||
+1032
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user