diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5dd953b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,129 @@ +name: Build & Release + +# Trigger on version tags (e.g. git tag v1.0.0 && git push --tags) +on: + push: + tags: + - "v*" + # Manual run from Actions tab (useful for testing the workflow itself) + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: macOS + artifact: BetterClaudeConfig-macOS.zip + - os: windows-latest + platform: Windows + artifact: BetterClaudeConfig-Windows.zip + - os: ubuntu-latest + platform: Linux + artifact: BetterClaudeConfig-Linux.tar.gz + + runs-on: ${{ matrix.os }} + name: Build (${{ matrix.platform }}) + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: pip install -r requirements-dev.txt + + # macOS: build app.icns from source PNGs (iconutil is built into macOS) + - name: Generate app.icns (macOS) + if: runner.os == 'macOS' + run: python scripts/build_icons.py + + # Windows: generate app.ico using Pillow (already installed via requirements-dev.txt) + - name: Generate app.ico (Windows) + if: runner.os == 'Windows' + run: python scripts/build_icons.py + + - name: Build with PyInstaller + run: pyinstaller bcc.spec + + # ── Package ────────────────────────────────────────────────────────── + + - name: Package (macOS) + if: runner.os == 'macOS' + run: | + cd dist + zip -r --symlinks "../${{ matrix.artifact }}" BetterClaudeConfig.app + + - name: Package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Compress-Archive -Path dist\BetterClaudeConfig.exe ` + -DestinationPath "${{ matrix.artifact }}" + + - name: Package (Linux) + if: runner.os == 'Linux' + run: | + tar -czf "${{ matrix.artifact }}" -C dist BetterClaudeConfig + + # ── Upload artifact for the release job ────────────────────────────── + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.artifact }} + + # ── Create GitHub Release with all three artifacts ────────────────────── + + release: + name: Publish Release + needs: build + runs-on: ubuntu-latest + # Only publish when a tag was pushed (not on workflow_dispatch without a tag) + if: startsWith(github.ref, 'refs/tags/') + permissions: + contents: write + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + name: Better Claude Config ${{ github.ref_name }} + draft: false + prerelease: false + generate_release_notes: true + files: artifacts/* + body: | + ## Better Claude Config ${{ github.ref_name }} + + A GUI for managing MCP server configurations for Claude Desktop and Claude Code — no hand-editing JSON. + + ### Download + + | Platform | File | + |----------|------| + | macOS | `BetterClaudeConfig-macOS.zip` — unzip and drag **BetterClaudeConfig.app** to Applications | + | Windows | `BetterClaudeConfig-Windows.zip` — unzip and run **BetterClaudeConfig.exe** | + | Linux | `BetterClaudeConfig-Linux.tar.gz` — extract and run **BetterClaudeConfig** | + + ### macOS note + The app is not code-signed. On first launch, right-click → **Open** to bypass Gatekeeper, or run: + ``` + xattr -cr /Applications/BetterClaudeConfig.app + ``` + + ### Requirements + No Python installation needed — the app is self-contained. diff --git a/.gitignore b/.gitignore index 9f8832b..cf4c94d 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ venv/ # PyInstaller / build build/ dist/ -*.spec +# Note: bcc.spec is tracked; *.spec here would exclude it — keep this explicit: +*.spec.bak # App runtime .bcc_backups/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8713e97 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install runtime dependency +pip install -r requirements.txt +python bcc.py # run the GUI +python test_core.py # run unit tests (23 tests, no GUI needed) + +# Build a self-contained binary +pip install -r requirements-dev.txt +python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed +pyinstaller bcc.spec +# macOS → dist/BetterClaudeConfig.app +# Windows → dist/BetterClaudeConfig.exe +# Linux → dist/BetterClaudeConfig +``` + +Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Build-time: `pyinstaller>=6.0`, `pillow>=10.0`. + +## Architecture + +The codebase is split into two layers: + +**`bcc_core.py`** — All logic with no GUI imports. Contains: +- `Profile` / `ServerEntry` dataclasses (the data model) +- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform) +- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/` +- `parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object) +- `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.) +- `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread + +**`bcc.py`** — PySide6 GUI that is a thin shell over `bcc_core`. Key classes: +- `MainWindow` — manages the profile combo, two server tables (active/disabled), action bar, and dirty state +- `ServerEditor` — right-panel form with a `QStackedWidget` for stdio vs remote pages; calls `core.check_dependency()` on every field change +- `KeyValueTable` — reusable widget for env vars and headers +- `ConnTester(QThread)` — background thread for remote reachability tests + +**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order. + +Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition. + +## Packaging + +- **`bcc.spec`** — PyInstaller spec; handles macOS (onedir → `.app` bundle) and Windows/Linux (onefile). Platform-aware icon selection. +- **`icons/app.icns`** (macOS) and **`icons/app.ico`** (Windows) are pre-generated and committed; source PNGs are in `icons/twin-gears/rounded/`. +- **`scripts/build_icons.py`** — regenerates both icon files from source PNGs. Uses `iconutil` (macOS-only) for `.icns`, Pillow for `.ico`. +- **`.github/workflows/release.yml`** — builds on all three platforms on tag push, publishes as GitHub Release assets. + +To release: `git tag vX.Y.Z && git push --tags`. + +## Key constants + +- `ACCENT` at the top of `bcc.py` — the one constant to change for a rebrand +- `KNOWN_FIELDS` in `bcc_core.py` — fields the editor renders; anything else on a server object is preserved as `_extra` in `ServerEditor` +- `DISABLED_KEY = "_disabledMcpServers"` — the parking key for disabled servers +- `MAX_BACKUPS = 15` — rolling backup limit per config file diff --git a/README.md b/README.md index e607292..05282bc 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,39 @@ # Better Claude Config (BCC) -A small, cross-platform GUI for editing the `mcpServers` block of one or more -Claude Desktop installs — without ever hand-writing JSON. +A small, cross-platform GUI for editing the `mcpServers` block of Claude Desktop +and Claude Code installs — without ever hand-writing JSON. BCC only ever touches `mcpServers` (and its own `_disabledMcpServers` parking key). Every other key in your config is preserved verbatim, in its original order. Each write is atomic and makes a timestamped backup first. -## Install & run +## Download (no Python required) + +Pre-built self-contained binaries are attached to every [GitHub Release](../../releases): + +| Platform | Download | Run | +|----------|----------|-----| +| macOS | `BetterClaudeConfig-macOS.zip` | Unzip → drag **BetterClaudeConfig.app** to Applications | +| Windows | `BetterClaudeConfig-Windows.zip` | Unzip → double-click **BetterClaudeConfig.exe** | +| Linux | `BetterClaudeConfig-Linux.tar.gz` | Extract → run **BetterClaudeConfig** | + +> **macOS Gatekeeper note:** the app is not notarized. On first launch, right-click → **Open**, or run `xattr -cr /Applications/BetterClaudeConfig.app` in a terminal. + +## Run from source ```bash pip install -r requirements.txt python bcc.py ``` -(Python 3.10+.) +(Python 3.10+, PySide6 6.6+.) ## What it does - **Auto-discovers installs** — scans the platform's app-support folder for any - `Claude*` directory (so `Claude` and `Claude-Work` both show up), and lets you - **Add config…** to point at any file manually. + `Claude*` directory (so `Claude` and `Claude-Work` both show up) **and** finds + Claude Code at `~/.claude/settings.json`. Use **Add config…** to point at any + other file manually. - **Form-based editing** — name, command, args (one per line), env vars, or for remote servers: URL, transport, and headers. No raw JSON. - **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block, @@ -55,17 +68,40 @@ After saving, **restart that Claude install** for changes to take effect. ## Config locations it scans -| OS | Base it scans for `Claude*` | -|---------|---------------------------------| +**Claude Desktop** + +| OS | Base it scans for `Claude*` | +|---------|----------------------------------| | macOS | `~/Library/Application Support` | | Windows | `%APPDATA%` | | Linux | `~/.config` | +**Claude Code** (all platforms): `~/.claude/settings.json` + ## Files - `bcc.py` — the GUI. - `bcc_core.py` — all the file/JSON/validation/dependency logic (no GUI deps). - `test_core.py` — unit suite for the core (`python test_core.py`). +- `bcc.spec` — PyInstaller build spec (cross-platform). +- `scripts/build_icons.py` — regenerates `icons/app.icns` and `icons/app.ico` from source PNGs. + +## Building from source + +```bash +pip install -r requirements-dev.txt +python scripts/build_icons.py # regenerate icons if needed +pyinstaller bcc.spec +# macOS → dist/BetterClaudeConfig.app +# Windows → dist/BetterClaudeConfig.exe +# Linux → dist/BetterClaudeConfig +``` + +Releases are built automatically by GitHub Actions (`.github/workflows/release.yml`) when a version tag is pushed: + +```bash +git tag v1.0.0 && git push --tags +``` ## Rebrand diff --git a/bcc.spec b/bcc.spec new file mode 100644 index 0000000..6eb5854 --- /dev/null +++ b/bcc.spec @@ -0,0 +1,111 @@ +# -*- mode: python ; coding: utf-8 -*- +# +# PyInstaller spec for Better Claude Config. +# +# macOS → onedir build wrapped in a .app bundle (double-click native) +# Windows → onefile .exe (no console window) +# Linux → onefile binary (no console window) +# +# Build: +# pip install pyinstaller +# pyinstaller bcc.spec +# +# Output: +# macOS → dist/BetterClaudeConfig.app +# Windows → dist/BetterClaudeConfig.exe +# Linux → dist/BetterClaudeConfig + +import sys + +APP_NAME = "BetterClaudeConfig" +BUNDLE_ID = "com.betterclaude.config" + +if sys.platform == "darwin": + icon = "icons/app.icns" +elif sys.platform == "win32": + icon = "icons/app.ico" +else: + icon = None # Linux: icon embedded via .desktop file, not the binary + +a = Analysis( + ["bcc.py"], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=["_tkinter", "tkinter"], + noarchive=False, +) +pyz = PYZ(a.pure) + +if sys.platform == "darwin": + # ------------------------------------------------------------------ macOS + # onedir so PyInstaller produces a proper .app bundle structure. + exe = EXE( + pyz, + a.scripts, + [], + exclude_binaries=True, + name=APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + argv_emulation=True, # lets macOS open files by passing argv + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=icon, + ) + coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=False, + upx_exclude=[], + name=APP_NAME, + ) + app = BUNDLE( + coll, + name=f"{APP_NAME}.app", + icon=icon, + bundle_identifier=BUNDLE_ID, + info_plist={ + "CFBundleName": "Better Claude Config", + "CFBundleDisplayName": "Better Claude Config", + "CFBundleShortVersionString": "1.0.0", + "CFBundleVersion": "1.0.0", + "NSHighResolutionCapable": True, + "NSRequiresAquaSystemAppearance": False, # supports dark mode + "LSMinimumSystemVersion": "11.0", + }, + ) + +else: + # ----------------------------------------------- Windows / Linux (onefile) + exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name=APP_NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=icon, + ) diff --git a/bcc_core.py b/bcc_core.py index 0413854..20ea514 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -78,11 +78,12 @@ def app_support_base() -> Path: 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. - This catches the standard `Claude` folder plus any sibling like - `Claude-Work`, `Claude-Personal`, etc. A directory is listed even if the - config file doesn't exist yet (it will be created on first save). + Claude Desktop: scans the platform app-support folder for any `Claude*` + directory (catches `Claude`, `Claude-Work`, etc.). + Claude Code: always at ~/.claude/settings.json on every platform. """ base = app_support_base() out: list[Profile] = [] @@ -93,6 +94,12 @@ def discover_profiles() -> list[Profile]: seen.add(d.name) cfg = d / CONFIG_FILENAME out.append(Profile(label=d.name, path=cfg, config_exists=cfg.is_file())) + + # Claude Code global settings (~/.claude/settings.json) — same mcpServers format, + # cross-platform (the ~/.claude/ directory is always in the same place). + cc_cfg = Path.home() / ".claude" / "settings.json" + out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) + return out diff --git a/icons/app.icns b/icons/app.icns new file mode 100644 index 0000000..44bb9c2 Binary files /dev/null and b/icons/app.icns differ diff --git a/icons/app.ico b/icons/app.ico new file mode 100644 index 0000000..62a9f2c Binary files /dev/null and b/icons/app.ico differ diff --git a/icons/twin-gears/rounded/icon-1024.png b/icons/twin-gears/rounded/icon-1024.png new file mode 100644 index 0000000..72939db Binary files /dev/null and b/icons/twin-gears/rounded/icon-1024.png differ diff --git a/icons/twin-gears/rounded/icon-128.png b/icons/twin-gears/rounded/icon-128.png new file mode 100644 index 0000000..e65177b Binary files /dev/null and b/icons/twin-gears/rounded/icon-128.png differ diff --git a/icons/twin-gears/rounded/icon-16.png b/icons/twin-gears/rounded/icon-16.png new file mode 100644 index 0000000..f1b5976 Binary files /dev/null and b/icons/twin-gears/rounded/icon-16.png differ diff --git a/icons/twin-gears/rounded/icon-256.png b/icons/twin-gears/rounded/icon-256.png new file mode 100644 index 0000000..040a158 Binary files /dev/null and b/icons/twin-gears/rounded/icon-256.png differ diff --git a/icons/twin-gears/rounded/icon-32.png b/icons/twin-gears/rounded/icon-32.png new file mode 100644 index 0000000..99febd0 Binary files /dev/null and b/icons/twin-gears/rounded/icon-32.png differ diff --git a/icons/twin-gears/rounded/icon-48.png b/icons/twin-gears/rounded/icon-48.png new file mode 100644 index 0000000..8be073f Binary files /dev/null and b/icons/twin-gears/rounded/icon-48.png differ diff --git a/icons/twin-gears/rounded/icon-512.png b/icons/twin-gears/rounded/icon-512.png new file mode 100644 index 0000000..769c683 Binary files /dev/null and b/icons/twin-gears/rounded/icon-512.png differ diff --git a/icons/twin-gears/rounded/icon-64.png b/icons/twin-gears/rounded/icon-64.png new file mode 100644 index 0000000..0ea5aea Binary files /dev/null and b/icons/twin-gears/rounded/icon-64.png differ diff --git a/icons/twin-gears/square/icon-1024.png b/icons/twin-gears/square/icon-1024.png new file mode 100644 index 0000000..35a90ab Binary files /dev/null and b/icons/twin-gears/square/icon-1024.png differ diff --git a/icons/twin-gears/square/icon-128.png b/icons/twin-gears/square/icon-128.png new file mode 100644 index 0000000..16608c8 Binary files /dev/null and b/icons/twin-gears/square/icon-128.png differ diff --git a/icons/twin-gears/square/icon-256.png b/icons/twin-gears/square/icon-256.png new file mode 100644 index 0000000..edd1e05 Binary files /dev/null and b/icons/twin-gears/square/icon-256.png differ diff --git a/icons/twin-gears/square/icon-512.png b/icons/twin-gears/square/icon-512.png new file mode 100644 index 0000000..51e6a4b Binary files /dev/null and b/icons/twin-gears/square/icon-512.png differ diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..a50d6fa --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,6 @@ +# Runtime (also in requirements.txt) +PySide6>=6.6 + +# Build / packaging +pyinstaller>=6.0 +pillow>=10.0 # generates icons/app.ico during CI (Windows build) diff --git a/scripts/build_icons.py b/scripts/build_icons.py new file mode 100644 index 0000000..b62496c --- /dev/null +++ b/scripts/build_icons.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Generate icons/app.ico (Windows) and icons/app.icns (macOS) from the +rounded PNG source files in icons/twin-gears/rounded/. + +Run from the repo root: + python scripts/build_icons.py + +Requirements: + pip install pillow # for .ico + iconutil # built into macOS; for .icns +""" +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).parent.parent +SRC = ROOT / "icons" / "twin-gears" / "rounded" +OUT = ROOT / "icons" + + +def build_ico(): + try: + from PIL import Image + except ImportError: + print("Pillow not installed — skipping .ico generation (pip install pillow)") + return + + sizes = [16, 32, 48, 64, 128, 256] + imgs = [] + for s in sizes: + p = SRC / f"icon-{s}.png" + if not p.exists(): + print(f" Missing {p.name}, skipping") + continue + imgs.append(Image.open(p).convert("RGBA")) + + if not imgs: + print(" No source PNGs found — cannot build .ico") + return + + dest = OUT / "app.ico" + imgs[-1].save(dest, format="ICO", append_images=imgs[:-1]) + print(f" Generated {dest.relative_to(ROOT)} ({dest.stat().st_size // 1024 + 1} KB)") + + +def build_icns(): + if shutil.which("iconutil") is None: + print(" iconutil not found (macOS-only) — skipping .icns generation") + return + + iconset = Path(tempfile.mkdtemp()) / "app.iconset" + iconset.mkdir() + mapping = { + "icon_16x16.png": "icon-16.png", + "icon_16x16@2x.png": "icon-32.png", + "icon_32x32.png": "icon-32.png", + "icon_32x32@2x.png": "icon-64.png", + "icon_128x128.png": "icon-128.png", + "icon_128x128@2x.png": "icon-256.png", + "icon_256x256.png": "icon-256.png", + "icon_256x256@2x.png": "icon-512.png", + "icon_512x512.png": "icon-512.png", + "icon_512x512@2x.png": "icon-1024.png", + } + for dst_name, src_name in mapping.items(): + src = SRC / src_name + if src.exists(): + shutil.copy2(src, iconset / dst_name) + else: + print(f" Missing {src_name}, skipping {dst_name}") + + dest = OUT / "app.icns" + subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(dest)], + check=True, + ) + shutil.rmtree(iconset.parent) + print(f" Generated {dest.relative_to(ROOT)} ({dest.stat().st_size // 1024} KB)") + + +if __name__ == "__main__": + print("Building icons/app.ico ...") + build_ico() + print("Building icons/app.icns ...") + build_icns() + print("Done.") diff --git a/test_core.py b/test_core.py index acffe4c..e7d5bec 100644 --- a/test_core.py +++ b/test_core.py @@ -18,7 +18,8 @@ for d in ("Claude", "Claude-Work", "NotClaude"): c.app_support_base = lambda: base profs = c.discover_profiles() labels = sorted(p.label for p in profs) -check("discovers Claude + Claude-Work, not NotClaude", labels == ["Claude", "Claude-Work"]) +check("discovers Claude + Claude-Work, not NotClaude", + "Claude" in labels and "Claude-Work" in labels and "NotClaude" not in labels) check("flags missing config file", any(p.label=="Claude-Work" and not p.config_exists for p in profs)) # ---- 2. write preserves OTHER keys + order, only touches mcpServers ----