28 Commits

Author SHA1 Message Date
the_og e6b60e94e7 Merge pull request 'release: v1.3.0 — named server sets, project config discovery, schema lint, UX polish' (#60) from release/v1.3.0 into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
Build & Release / Build (Windows) (push) Successful in 53s
Build & Release / Build (Linux) (push) Successful in 1m0s
Build & Release / Build (macOS) (push) Successful in 1m58s
Build & Release / Publish Release (push) Successful in 10s
2026-07-12 14:03:50 -04:00
Cowork Supervisor 4afe21666d release: bump version to 1.3.0
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
Named server sets (#52), project .mcp.json discovery (#53), schema
lint (#54), Ctrl+S + enable/disable-all (#55).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 14:02:38 -04:00
the_og 06e74d4d2c Merge pull request 'feat: Ctrl+S save shortcut + enable/disable-all actions (#55)' (#59) from feat/55-ux-polish into main
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:02:23 -04:00
Cowork Supervisor f92b851127 Merge remote-tracking branch 'origin/main' into feat/55-ux-polish
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
2026-07-12 14:00:56 -04:00
the_og 47c95ac006 Merge pull request 'feat: named server sets — save/apply the Active/Disabled split (#52)' (#56) from feat/52-server-sets into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:00:19 -04:00
the_og 6b22ad26f0 Merge pull request 'feat: structural schema lint for server definitions (#54)' (#58) from feat/54-schema-lint into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 14:00:12 -04:00
the_og 874948506c Merge pull request 'feat: discover Claude Code project .mcp.json configs as profiles (#53)' (#57) from feat/53-project-mcp-discovery into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 13:58:52 -04:00
Cowork Supervisor ac2e73e9d7 feat: named server sets — save/apply the Active/Disabled split (#52)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
Sets live in the config under _bccServerSets (bcc-owned, ignored by
Claude, travels with the file). Apply enables exactly the set's members
and parks the rest; vanished members are reported, not fatal. GUI row:
set combo + Apply + Save set… + delete.

Closes #52

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:55:36 -04:00
Cowork Supervisor 82ff149373 feat: structural schema lint for server definitions (#54)
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 19s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
Closes #54

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:55:22 -04:00
Cowork Supervisor 31ef4a0e85 feat: Ctrl+S save shortcut + enable/disable-all actions (#55)
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
Closes #55

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:55:19 -04:00
Cowork Supervisor 520b1b2ffd feat: discover Claude Code project .mcp.json configs as profiles (#53)
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 9s
Closes #53

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:53:38 -04:00
the_og 9f535fb77f Merge pull request 'release: v1.2.1 — first shipped build with icon fix, audit fixes #32–#40, Windows tree-kill (#13)' (#51) from release/v1.2.1 into main
CI / Lint (ruff) (push) Successful in 9s
Build & Release / Build (Linux) (push) Successful in 1m0s
Build & Release / Build (Windows) (push) Successful in 58s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 8s
Build & Release / Build (macOS) (push) Successful in 1m43s
Build & Release / Publish Release (push) Successful in 9s
2026-07-12 13:33:26 -04:00
Cowork Supervisor 8cf19d43c4 test: version-sync test reads pyproject.toml instead of hard-coding
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
CI / Lint (ruff) (pull_request) Successful in 7s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:32:23 -04:00
Cowork Supervisor 0ef4586698 release: bump version to 1.2.1
First release actually shipping the v1.2.0 feature set (the v1.2.0
tag's release run was cancelled and produced no assets) plus the
audit fixes #32-#40 and the Windows process-tree kill (#13).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:31:36 -04:00
the_og ed7c40cac9 Merge pull request 'fix: kill the whole process tree on Windows spawn-test timeout (#13)' (#50) from fix/13-windows-tree-kill into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.12 / windows-latest) (push) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 9s
2026-07-12 13:31:15 -04:00
Cowork Supervisor 1384ed9703 fix: kill the whole process tree on Windows spawn-test timeout (#13)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 9s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
Popen.kill() only terminated the direct child, so runner-style commands
(npx -> node -> server) leaked the real server process on every Windows
spawn test. taskkill /PID <pid> /T /F walks the descendant tree. Also
sets CREATE_NO_WINDOW on the spawned test process so the windowed exe
doesn't flash a console per test.

Closes #13

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:30:02 -04:00
the_og 41891ddad4 Merge pull request 'fix: block duplicate env/header keys at entry instead of silently dropping one (#38)' (#49) from fix/38-duplicate-kv-keys into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 13s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 12s
2026-07-12 13:06:54 -04:00
the_og 408f517c5d Merge pull request 'fix: pop old AND new name from health cache on rename (#36)' (#48) from fix/36-health-cache-rename into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 17s
CI / Lint (ruff) (push) Successful in 11s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 14s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 13s
2026-07-12 13:06:48 -04:00
the_og 9036729cd8 Merge pull request 'fix: MSIX warning gets a persistent banner instead of the status bar (#35)' (#47) from fix/35-msix-banner into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 14s
CI / Lint (ruff) (push) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 14s
2026-07-12 13:06:41 -04:00
the_og 29a08e9532 Merge pull request 'ci: test on windows + python 3.13 (#40)' (#46) from ci/40-test-matrix into main
CI / Tests (py3.12 / windows-latest) (push) Successful in 18s
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 12s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
2026-07-12 13:06:34 -04:00
Cowork Supervisor 87303809b8 test: dep-check probe command must exist on Windows too
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 17s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 8s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 8s
python3 is not a command on a stock Windows install; caught by the new
windows-latest CI job. Probe 'python' there and accept warn (found on
augmented PATH) as proof of resolution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:05:04 -04:00
Cowork Supervisor 42456f25d2 fix: block duplicate env/header keys at entry instead of silently dropping one (#38)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #38

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

Closes #35

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:03:57 -04:00
Cowork Supervisor 5c476bb13f fix: pop old AND new name from health cache on rename (#36)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 9s
CI / Tests (py3.12) (pull_request) Successful in 10s
Closes #36

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 13:02:09 -04:00
the_og b485357cd5 Merge pull request 'fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)' (#45) from fix/39-drop-import-all into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 9s
2026-07-12 13:00:43 -04:00
Cowork Supervisor d95db2b026 fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #39

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:47:14 -04:00
6 changed files with 712 additions and 33 deletions
+20 -4
View File
@@ -29,21 +29,37 @@ jobs:
run: ruff format --check . run: ruff format --check .
test: test:
runs-on: ubuntu-latest runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }}) name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
python: ["3.10", "3.12"] os: [ubuntu-latest]
python: ["3.10", "3.12", "3.13"]
include:
# Windows tests on 3.12 only — the version the release binaries ship
# with. The self-hosted Windows runner blocks setup-python's install
# script (PowerShell execution policy), so it uses the host's `py`
# launcher + venv, same as release.yml.
- os: windows-latest
python: "3.12"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python }} - name: Set up Python ${{ matrix.python }} (Linux)
if: runner.os == 'Linux'
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: ${{ matrix.python }} python-version: ${{ matrix.python }}
- name: Set up Python venv (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
py -${{ matrix.python }} -m venv .venv
Add-Content -Path $env:GITHUB_PATH -Value "$env:GITHUB_WORKSPACE\.venv\Scripts"
# bcc_core has no GUI imports, so the test suite needs no PySide6 — # bcc_core has no GUI imports, so the test suite needs no PySide6 —
# keeps CI fast and avoids Qt system-library headaches on the runner. # keeps CI fast and avoids Qt system-library headaches on the runner.
- name: Install test dependencies - name: Install test dependencies
+234 -19
View File
@@ -19,9 +19,11 @@ from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, S
from PySide6.QtGui import ( from PySide6.QtGui import (
QAction, QAction,
QColor, QColor,
QCursor,
QDesktopServices, QDesktopServices,
QGuiApplication, QGuiApplication,
QIcon, QIcon,
QKeySequence,
QPainter, QPainter,
QPixmap, QPixmap,
) )
@@ -37,6 +39,7 @@ from PySide6.QtWidgets import (
QGridLayout, QGridLayout,
QHBoxLayout, QHBoxLayout,
QHeaderView, QHeaderView,
QInputDialog,
QLabel, QLabel,
QLineEdit, QLineEdit,
QListWidget, QListWidget,
@@ -51,12 +54,17 @@ from PySide6.QtWidgets import (
QStyledItemDelegate, QStyledItemDelegate,
QTableWidget, QTableWidget,
QTableWidgetItem, QTableWidgetItem,
QToolTip,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
import bcc_core as core import bcc_core as core
# A full ~/.claude.json with history can be huge; parsing happens on the UI
# thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- One-line rebrand: change this to recolor the whole app --------------- #
ACCENT = "#f97316" # warm orange ACCENT = "#f97316" # warm orange
ACCENT_DIM = "#c2570b" ACCENT_DIM = "#c2570b"
@@ -121,6 +129,7 @@ QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }}
QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }} QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; }}
QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }} QLabel#statusbar {{ color: {MUTED}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }}
@@ -266,10 +275,31 @@ class KeyValueTable(QWidget):
self.reveal_btn.setText("Hide secrets" if on else "Show secrets") self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
self.table.viewport().update() self.table.viewport().update()
def _changed(self, *_): def _changed(self, item=None, *_):
if item is not None and item.column() == 0:
new_key = item.text().strip()
row = item.row()
if new_key and self._is_duplicate_key(new_key, row):
prev_key = item.data(Qt.ItemDataRole.UserRole)
prev_key = prev_key if prev_key is not None else ""
self.table.blockSignals(True)
item.setText(prev_key)
self.table.blockSignals(False)
QToolTip.showText(QCursor.pos(), f"Duplicate key '{new_key}' — reverted.")
return
item.setData(Qt.ItemDataRole.UserRole, new_key)
if self._on_change: if self._on_change:
self._on_change() self._on_change()
def _is_duplicate_key(self, key: str, ignore_row: int) -> bool:
for r in range(self.table.rowCount()):
if r == ignore_row:
continue
other = self.table.item(r, 0)
if other and other.text().strip() == key:
return True
return False
def _add_row(self): def _add_row(self):
dlg = QDialog(self.window()) dlg = QDialog(self.window())
dlg.setWindowTitle(f"Add {self._key_label}") dlg.setWindowTitle(f"Add {self._key_label}")
@@ -296,6 +326,13 @@ class KeyValueTable(QWidget):
grid.addWidget(val_edit, 1, 1) grid.addWidget(val_edit, 1, 1)
v.addLayout(grid) v.addLayout(grid)
# Shown when the typed key already exists in the table.
dup_warn = QLabel("")
dup_warn.setStyleSheet(f"color: {WARN};")
dup_warn.setWordWrap(True)
dup_warn.hide()
v.addWidget(dup_warn)
# Type an API_KEY/TOKEN-style name and the value field masks itself. # Type an API_KEY/TOKEN-style name and the value field masks itself.
def _sync_echo(text): def _sync_echo(text):
secret = core.is_secret_key(text) secret = core.is_secret_key(text)
@@ -311,7 +348,15 @@ class KeyValueTable(QWidget):
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok) ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
ok_btn.setObjectName("primary") ok_btn.setObjectName("primary")
ok_btn.setEnabled(False) ok_btn.setEnabled(False)
key_edit.textChanged.connect(lambda t: ok_btn.setEnabled(bool(t.strip())))
def _validate(text):
k = text.strip()
dup = bool(k) and self._is_duplicate_key(k, ignore_row=-1)
ok_btn.setEnabled(bool(k) and not dup)
dup_warn.setText(f"'{k}' already exists" if dup else "")
dup_warn.setVisible(dup)
key_edit.textChanged.connect(_validate)
btns.accepted.connect(dlg.accept) btns.accepted.connect(dlg.accept)
btns.rejected.connect(dlg.reject) btns.rejected.connect(dlg.reject)
v.addWidget(btns) v.addWidget(btns)
@@ -325,13 +370,15 @@ class KeyValueTable(QWidget):
return return
k = key_edit.text().strip() k = key_edit.text().strip()
val = val_edit.text() val = val_edit.text()
if not k: if not k or self._is_duplicate_key(k, ignore_row=-1):
return return
if self._before_change: if self._before_change:
self._before_change() self._before_change()
r = self.table.rowCount() r = self.table.rowCount()
self.table.insertRow(r) self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(k)) key_item = QTableWidgetItem(k)
key_item.setData(Qt.ItemDataRole.UserRole, k)
self.table.setItem(r, 0, key_item)
self.table.setItem(r, 1, QTableWidgetItem(val)) self.table.setItem(r, 1, QTableWidgetItem(val))
self._changed() self._changed()
@@ -350,7 +397,9 @@ class KeyValueTable(QWidget):
for k, v in (d or {}).items(): for k, v in (d or {}).items():
r = self.table.rowCount() r = self.table.rowCount()
self.table.insertRow(r) self.table.insertRow(r)
self.table.setItem(r, 0, QTableWidgetItem(str(k))) key_item = QTableWidgetItem(str(k))
key_item.setData(Qt.ItemDataRole.UserRole, str(k))
self.table.setItem(r, 0, key_item)
self.table.setItem(r, 1, QTableWidgetItem(str(v))) self.table.setItem(r, 1, QTableWidgetItem(str(v)))
self.table.blockSignals(False) self.table.blockSignals(False)
@@ -1492,6 +1541,15 @@ class MainWindow(QMainWindow):
root.addLayout(self._build_topbar()) root.addLayout(self._build_topbar())
# Persistent warning banner (MSIX-virtualized config, etc.). Lives in
# its own widget because the status bar is rewritten on every action,
# which used to wipe the warning before the user could read it.
self.warn_banner = QLabel("")
self.warn_banner.setObjectName("warnBanner")
self.warn_banner.setWordWrap(True)
self.warn_banner.hide()
root.addWidget(self.warn_banner)
# User-draggable divider between the server list and the editor. # User-draggable divider between the server list and the editor.
split = QSplitter(Qt.Orientation.Horizontal) split = QSplitter(Qt.Orientation.Horizontal)
split.setChildrenCollapsible(False) split.setChildrenCollapsible(False)
@@ -1630,11 +1688,45 @@ class MainWindow(QMainWindow):
head.setObjectName("h1") head.setObjectName("h1")
v.addWidget(head) v.addWidget(head)
search_row = QHBoxLayout()
self.search_box = QLineEdit() self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search servers by name, command, or url…") self.search_box.setPlaceholderText("Search servers by name, command, or url…")
self.search_box.setClearButtonEnabled(True) self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._on_search_changed) self.search_box.textChanged.connect(self._on_search_changed)
v.addWidget(self.search_box) search_row.addWidget(self.search_box, 1)
self.enable_all_btn = QPushButton("All on")
self.enable_all_btn.setToolTip("Enable every server")
self.enable_all_btn.clicked.connect(lambda: self._set_all_enabled(True))
search_row.addWidget(self.enable_all_btn)
self.disable_all_btn = QPushButton("All off")
self.disable_all_btn.setToolTip("Disable every server")
self.disable_all_btn.clicked.connect(lambda: self._set_all_enabled(False))
search_row.addWidget(self.disable_all_btn)
v.addLayout(search_row)
# Named server sets (issue #52): apply a saved Active/Disabled split
# in one click. Sets live in the config file under _bccServerSets.
sets_row = QHBoxLayout()
sets_lbl = QLabel("Set")
sets_lbl.setObjectName("muted")
sets_row.addWidget(sets_lbl)
self.sets_combo = QComboBox()
self.sets_combo.setMinimumWidth(120)
sets_row.addWidget(self.sets_combo, 1)
self.apply_set_btn = QPushButton("Apply")
self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest")
self.apply_set_btn.clicked.connect(self._apply_selected_set)
sets_row.addWidget(self.apply_set_btn)
self.save_set_btn = QPushButton("Save set…")
self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set")
self.save_set_btn.clicked.connect(self._save_set)
sets_row.addWidget(self.save_set_btn)
self.del_set_btn = QPushButton("")
self.del_set_btn.setToolTip("Delete the selected set")
self.del_set_btn.setMaximumWidth(32)
self.del_set_btn.clicked.connect(self._delete_set)
sets_row.addWidget(self.del_set_btn)
v.addLayout(sets_row)
# Active and Disabled sections live in a vertical splitter so the user # Active and Disabled sections live in a vertical splitter so the user
# can drag the divider instead of being stuck with a fixed-height # can drag the divider instead of being stuck with a fixed-height
@@ -1718,6 +1810,12 @@ class MainWindow(QMainWindow):
undo_action.setShortcut("Ctrl+Z") undo_action.setShortcut("Ctrl+Z")
undo_action.triggered.connect(self._undo) undo_action.triggered.connect(self._undo)
self.addAction(undo_action) self.addAction(undo_action)
# Ctrl+S / Cmd+S shortcut — routed through a guard so it respects
# the same dirty/validation gating as the Save button.
save_action = QAction(self)
save_action.setShortcut(QKeySequence.StandardKey.Save)
save_action.triggered.connect(self._save_shortcut)
self.addAction(save_action)
bar.addStretch() bar.addStretch()
self.validation_lbl = QLabel("") self.validation_lbl = QLabel("")
bar.addWidget(self.validation_lbl) bar.addWidget(self.validation_lbl)
@@ -1744,6 +1842,19 @@ class MainWindow(QMainWindow):
self._mark_dirty() self._mark_dirty()
self.status.setText("Undone.") self.status.setText("Undone.")
def _set_all_enabled(self, enabled: bool):
"""Flip every server's enabled flag in one step (one undo snapshot)."""
if not self.servers or all(s.enabled == enabled for s in self.servers):
return # nothing to change
cur = self._current_index()
self._push_undo()
for s in self.servers:
s.enabled = enabled
sel = cur if 0 <= cur < len(self.servers) else None
self._refresh_tables(select_index=sel)
self._mark_dirty()
self.status.setText("All servers enabled." if enabled else "All servers disabled.")
# --- profiles -------------------------------------------------------- # # --- profiles -------------------------------------------------------- #
def reload_profiles(self): def reload_profiles(self):
discovered = core.discover_profiles() discovered = core.discover_profiles()
@@ -1766,8 +1877,9 @@ class MainWindow(QMainWindow):
def _maybe_warn_msix(self): def _maybe_warn_msix(self):
""" """
Windows-only, no-op everywhere else: if Claude Desktop looks like an Windows-only, no-op everywhere else: if Claude Desktop looks like an
MSIX/Store install with a virtualized config, append a warning to the MSIX/Store install with a virtualized config, show a persistent banner
status bar so edits to the plain %APPDATA% path aren't silently lost. so edits to the plain %APPDATA% path aren't silently lost. (The status
bar is the wrong home for this: it's rewritten on every action.)
Defensive on purpose -- this must never block startup or profile load. Defensive on purpose -- this must never block startup or profile load.
""" """
try: try:
@@ -1775,8 +1887,11 @@ class MainWindow(QMainWindow):
except Exception: except Exception:
return return
if warning: if warning:
self.status.setText(f"{self.status.text()} {warning}") self.warn_banner.setText(f" {warning}")
self.status.setToolTip(warning) self.warn_banner.setToolTip(warning)
self.warn_banner.show()
else:
self.warn_banner.hide()
def add_custom_config(self): def add_custom_config(self):
start = str(core.app_support_base()) start = str(core.app_support_base())
@@ -1832,6 +1947,7 @@ class MainWindow(QMainWindow):
self._undo_stack.clear() self._undo_stack.clear()
self.undo_btn.setEnabled(False) self.undo_btn.setEnabled(False)
self._health.clear() # health results are per-profile; a fresh load invalidates them self._health.clear() # health results are per-profile; a fresh load invalidates them
self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file
self._refresh_tables(select_index=0 if self.servers else -1) self._refresh_tables(select_index=0 if self.servers else -1)
self._update_status(saved=False) self._update_status(saved=False)
if repaired: if repaired:
@@ -1931,6 +2047,73 @@ class MainWindow(QMainWindow):
cur = self._current_index() cur = self._current_index()
self._refresh_tables(select_index=cur if cur >= 0 else None) self._refresh_tables(select_index=cur if cur >= 0 else None)
# --- named server sets (issue #52) ------------------------------------ #
def _refresh_sets_combo(self, select: str | None = None):
sets = core.list_server_sets(self.full_config)
self.sets_combo.blockSignals(True)
self.sets_combo.clear()
for name in sorted(sets):
self.sets_combo.addItem(name)
if select is not None:
idx = self.sets_combo.findText(select)
if idx >= 0:
self.sets_combo.setCurrentIndex(idx)
self.sets_combo.blockSignals(False)
has_sets = bool(sets)
self.apply_set_btn.setEnabled(has_sets)
self.del_set_btn.setEnabled(has_sets)
def _apply_selected_set(self):
name = self.sets_combo.currentText()
sets = core.list_server_sets(self.full_config)
if name not in sets:
return
self._push_undo()
missing = core.apply_server_set(self.servers, sets[name])
self._refresh_tables(select_index=self._current_index() if self.servers else None)
self._mark_dirty()
on = sum(1 for s in self.servers if s.enabled)
msg = f"Applied set “{name}” · {on} enabled. Review and Save."
if missing:
msg += f" ⚠ no longer in this config: {', '.join(missing)}"
self.status.setText(msg)
def _save_set(self):
name, ok = QInputDialog.getText(
self,
"Save server set",
"Set name (saves which servers are currently Active):",
text=self.sets_combo.currentText(),
)
name = name.strip()
if not ok or not name:
return
if name in core.list_server_sets(self.full_config) and (
QMessageBox.question(self, "Set exists", f"Replace set “{name}”?")
!= QMessageBox.StandardButton.Yes
):
return
members = core.save_server_set(self.full_config, name, self.servers)
self._refresh_sets_combo(select=name)
self._mark_dirty() # the set is written on the next Save
self.status.setText(
f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it."
)
def _delete_set(self):
name = self.sets_combo.currentText()
if not name:
return
if (
QMessageBox.question(self, "Delete set", f"Delete set “{name}”?")
!= QMessageBox.StandardButton.Yes
):
return
if core.delete_server_set(self.full_config, name):
self._refresh_sets_combo()
self._mark_dirty()
self.status.setText(f"Set “{name}” deleted. Press Save to write the change.")
# --- test all (spawn-test every enabled local server) ---------------- # # --- test all (spawn-test every enabled local server) ---------------- #
def _test_all_servers(self): def _test_all_servers(self):
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"] targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
@@ -2079,12 +2262,17 @@ class MainWindow(QMainWindow):
if not (0 <= idx < len(self.servers)): if not (0 <= idx < len(self.servers)):
return return
entry = self.servers[idx] entry = self.servers[idx]
old_name = entry.name
entry.name = self.editor.current_name() entry.name = self.editor.current_name()
entry.data = self.editor.dump_data() entry.data = self.editor.dump_data()
# The server stays in its section (enable state unchanged), so update # The server stays in its section (enable state unchanged), so update
# its existing row in place rather than re-rendering. # its existing row in place rather than re-rendering.
# An edit invalidates any cached "Test all" result -- the server that # An edit invalidates any cached "Test all" result -- the server that
# was spawn-tested no longer matches what's on disk once saved. # was spawn-tested no longer matches what's on disk once saved. Pop
# both names: the old one (so a rename doesn't leave a stale entry
# for whoever takes that name next) and the new one (so we don't
# inherit a stale result cached under the name being renamed to).
self._health.pop(old_name, None)
self._health.pop(entry.name, None) self._health.pop(entry.name, None)
loc = self._row_of_index.get(idx) loc = self._row_of_index.get(idx)
if loc: if loc:
@@ -2244,11 +2432,23 @@ class MainWindow(QMainWindow):
self.validation_lbl.setStyleSheet(f"color: {WARN};") self.validation_lbl.setStyleSheet(f"color: {WARN};")
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
return False return False
self.validation_lbl.setText("✓ valid") lint_warnings = core.lint_servers(self.servers)
self.validation_lbl.setStyleSheet(f"color: {GOOD};") if lint_warnings:
self.validation_lbl.setText(f"{lint_warnings[0]}")
self.validation_lbl.setStyleSheet(f"color: {WARN};")
else:
self.validation_lbl.setText("✓ valid")
self.validation_lbl.setStyleSheet(f"color: {GOOD};")
self.save_btn.setEnabled(self.dirty) self.save_btn.setEnabled(self.dirty)
return True return True
def _save_shortcut(self):
"""Ctrl+S / Cmd+S handler — only fires when the Save button itself
would accept a click, so the shortcut can't bypass validation/dirty
gating."""
if self.save_btn.isEnabled():
self.save()
def save(self): def save(self):
if not self.current_profile: if not self.current_profile:
return return
@@ -2402,29 +2602,44 @@ class MainWindow(QMainWindow):
e.acceptProposedAction() e.acceptProposedAction()
def dropEvent(self, e): def dropEvent(self, e):
total_added, total_replaced, files_imported = 0, 0, 0
undo_pushed = False
for u in e.mimeData().urls(): for u in e.mimeData().urls():
path = u.toLocalFile() path = u.toLocalFile()
if not path.endswith(".json"): if not path.endswith(".json"):
continue continue
text = Path(path).read_text(encoding="utf-8") p = Path(path)
if p.stat().st_size > MAX_DROP_IMPORT_BYTES:
QMessageBox.warning(
self,
"File too large",
f"{p.name}:\nFile exceeds the 5 MB import limit and was skipped.",
)
continue
text = p.read_text(encoding="utf-8")
try: try:
servers = core.parse_pasted_json(text) servers = core.parse_pasted_json(text)
except Exception as ex: except Exception as ex:
QMessageBox.warning(self, "Couldn't import", f"{Path(path).name}:\n{ex}") QMessageBox.warning(self, "Couldn't import", f"{p.name}:\n{ex}")
continue continue
self._push_undo() if not undo_pushed:
self._push_undo()
undo_pushed = True
added, replaced = 0, 0 added, replaced = 0, 0
for name, data in servers.items(): for name, data in servers.items():
a, r = self._import_server(name, data) a, r = self._import_server(name, data)
added += int(a) added += int(a)
replaced += int(r) replaced += int(r)
total_added += added
total_replaced += replaced
files_imported += 1
if files_imported:
self._refresh_tables(select_index=len(self.servers) - 1) self._refresh_tables(select_index=len(self.servers) - 1)
self._mark_dirty() self._mark_dirty()
self.status.setText( self.status.setText(
f"Imported {added} added, {replaced} replaced from {Path(path).name}. " f"Imported {total_added} added, {total_replaced} replaced from "
"Review and Save." f"{files_imported} file(s). Review and Save."
) )
break
def closeEvent(self, e): def closeEvent(self, e):
if self.dirty and not self._confirm_discard(): if self.dirty and not self._confirm_discard():
+2 -2
View File
@@ -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",
+194 -3
View File
@@ -39,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
@@ -59,7 +65,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"}
# binary. All network I/O here is fail-quiet (returns None on any problem) # 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. # so it's safe to run unattended, off the UI thread, at startup.
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
__version__ = "1.2.0" __version__ = "1.3.0"
REPO_URL = "https://git.avezzano.io/the_og/better-claude-config" REPO_URL = "https://git.avezzano.io/the_og/better-claude-config"
ISSUES_URL = f"{REPO_URL}/issues" ISSUES_URL = f"{REPO_URL}/issues"
@@ -282,10 +288,43 @@ def msix_warning_text(
) )
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.).
@@ -298,6 +337,10 @@ def discover_profiles() -> list[Profile]:
`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] = []
@@ -319,6 +362,12 @@ def discover_profiles() -> list[Profile]:
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
@@ -409,6 +458,70 @@ 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: def resolve_name_collision(name: str, existing: set[str]) -> str:
""" """
Return a name guaranteed not to collide with `existing`. Return a name guaranteed not to collide with `existing`.
@@ -1213,6 +1326,63 @@ 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 # Search / filter
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1581,6 +1751,23 @@ def server_log_path(name: str) -> Path | 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.
@@ -1675,6 +1862,10 @@ def _spawn_test_impl(data: dict, timeout: float) -> 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)
@@ -1696,7 +1887,7 @@ def _spawn_test_impl(data: dict, timeout: float) -> 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):
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "better-claude-config" name = "better-claude-config"
version = "1.2.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" }
+261 -4
View File
@@ -3,9 +3,11 @@ proper test functions with tmp_path/monkeypatch fixtures)."""
import json import json
import os import os
import re
import sys import sys
import urllib.error import urllib.error
import urllib.request import urllib.request
from pathlib import Path
import pytest import pytest
@@ -65,6 +67,64 @@ def test_legacy_settings_json_surfaced_only_with_servers(fake_home):
assert any("legacy" in p.label for p in c.discover_profiles()) assert any("legacy" in p.label for p in c.discover_profiles())
def test_project_with_mcp_json_is_discovered(tmp_path):
proj = tmp_path / "my-project"
proj.mkdir()
(proj / ".mcp.json").write_text('{"mcpServers": {"x": {"command": "npx"}}}')
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
profs = c.discover_project_configs(claude_json)
assert len(profs) == 1
assert profs[0].label == f"Project: {proj.name}"
assert profs[0].path == proj / ".mcp.json"
assert profs[0].config_exists
def test_project_without_mcp_json_not_listed(tmp_path):
proj = tmp_path / "no-mcp-project"
proj.mkdir()
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({"projects": {str(proj): {}}}))
assert c.discover_project_configs(claude_json) == []
def test_projects_missing_or_not_dict_yields_no_profiles(tmp_path):
claude_json = tmp_path / ".claude.json"
claude_json.write_text(json.dumps({}))
assert c.discover_project_configs(claude_json) == []
claude_json.write_text(json.dumps({"projects": ["not", "a", "dict"]}))
assert c.discover_project_configs(claude_json) == []
def test_malformed_claude_json_fails_quiet(fake_home):
(fake_home / ".claude.json").write_text("{not valid json")
assert c.discover_project_configs(fake_home / ".claude.json") == []
# discover_profiles as a whole must still work and just skip project profiles
profs = c.discover_profiles()
assert not any(p.label.startswith("Project:") for p in profs)
def test_project_configs_deduped_against_existing_profiles(fake_home):
# Point a project directly at the Claude Code profile's own .mcp.json-shaped
# path to prove discover_profiles() won't duplicate an already-listed path.
proj = fake_home / "dup-project"
proj.mkdir()
mcp_path = proj / ".mcp.json"
mcp_path.write_text('{"mcpServers": {"x": {"command": "npx"}}}')
(fake_home / ".claude.json").write_text(json.dumps({"projects": {str(proj): {}}}))
direct = c.discover_project_configs(fake_home / ".claude.json")
assert len(direct) == 1
profs = c.discover_profiles()
project_profiles = [p for p in profs if str(p.path) == str(mcp_path)]
assert len(project_profiles) == 1
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 2. Write pipeline: preserves other keys + order, only touches mcpServers # 2. Write pipeline: preserves other keys + order, only touches mcpServers
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -179,6 +239,85 @@ def test_valid_set_passes_clean():
assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == [] assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == []
# --------------------------------------------------------------------------- #
# 4b. Lint: non-blocking structural warnings
# --------------------------------------------------------------------------- #
def test_lint_flags_non_string_command():
warnings = c.lint_server("x", {"command": 5})
assert any("'command' should be a string" in w for w in warnings)
def test_lint_flags_args_not_a_list():
warnings = c.lint_server("x", {"command": "node", "args": "-y foo"})
assert any("'args' should be a list" in w for w in warnings)
def test_lint_flags_args_with_non_string_items():
warnings = c.lint_server("x", {"command": "node", "args": ["-y", 5]})
assert any("'args' contains non-string values" in w for w in warnings)
def test_lint_flags_env_not_a_dict():
warnings = c.lint_server("x", {"command": "node", "env": ["FOO=bar"]})
assert any("'env' should be an object" in w for w in warnings)
def test_lint_flags_env_with_non_string_values():
warnings = c.lint_server("x", {"command": "node", "env": {"FOO": 5}})
assert any("'env' contains non-string values" in w for w in warnings)
def test_lint_flags_headers_not_a_dict():
warnings = c.lint_server("x", {"url": "https://x", "headers": ["Authorization: x"]})
assert any("'headers' should be an object" in w for w in warnings)
def test_lint_flags_headers_with_non_string_values():
warnings = c.lint_server("x", {"url": "https://x", "headers": {"Authorization": 5}})
assert any("'headers' contains non-string values" in w for w in warnings)
def test_lint_flags_bad_type_value():
warnings = c.lint_server("x", {"url": "https://x", "type": "websocket"})
assert any("'type'" in w and "websocket" in w for w in warnings)
def test_lint_flags_extra_unknown_fields():
warnings = c.lint_server("x", {"command": "node", "cwd": "/tmp", "timeout": 30})
matches = [w for w in warnings if "extra fields preserved as-is" in w]
assert len(matches) == 1
assert "cwd" in matches[0]
assert "timeout" in matches[0]
def test_lint_clean_server_yields_no_warnings():
assert c.lint_server("good", {"command": "node", "args": ["a.js"]}) == []
assert (
c.lint_server(
"remote", {"url": "https://x", "type": "http", "headers": {"Authorization": "x"}}
)
== []
)
def test_lint_servers_aggregates_across_entries():
entries = [
c.ServerEntry("a", {"command": 5}, True),
c.ServerEntry("b", {"url": "https://x", "type": "bogus"}, True),
]
warnings = c.lint_servers(entries)
assert any("'a'" in w and "'command' should be a string" in w for w in warnings)
assert any("'b'" in w and "'type'" in w for w in warnings)
def test_lint_warning_is_not_a_blocking_problem():
# args given as a single string isn't caught by validate_servers (a
# command is still present), but it's a lint warning.
entry = c.ServerEntry("x", {"command": "node", "args": "-y foo"}, True)
assert c.validate_servers([entry]) == []
assert c.lint_servers([entry]) != []
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 5. Secrets: detection + redaction # 5. Secrets: detection + redaction
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -227,8 +366,11 @@ def test_diagnostics_redacts_token_args():
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# 6. Dependency / PATH checking # 6. Dependency / PATH checking
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dep_check_finds_python3(): def test_dep_check_finds_python():
assert c.check_dependency({"command": "python3"})["status"] == "ok" # "python3" does not exist on a stock Windows install; "warn" (found only
# on an augmented PATH) still proves resolution works on a real machine.
cmd = "python" if os.name == "nt" else "python3"
assert c.check_dependency({"command": cmd})["status"] in ("ok", "warn")
def test_dep_check_flags_missing(): def test_dep_check_flags_missing():
@@ -326,6 +468,55 @@ def test_spawn_test_large_stderr_does_not_deadlock():
assert len(r["stderr"]) <= c._STDERR_CAP assert len(r["stderr"]) <= c._STDERR_CAP
def test_windows_tree_kill_uses_taskkill(monkeypatch):
"""The Windows kill path must walk the process tree (taskkill /T /F);
Popen.kill() alone orphans grandchildren (issue #13)."""
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return _FakeRC()
class _FakeRC:
returncode = 0
monkeypatch.setattr(c.subprocess, "run", fake_run)
c._kill_process_tree_windows(1234)
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
"""A spawn-tested parent that has its own child (npx -> node style) must
not leave the child running after the timeout kill (issue #13)."""
import subprocess as sp
import time as _t
pid_file = tmp_path / "child.pid"
parent_script = (
"import subprocess, sys, time\n"
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
"time.sleep(60)\n"
)
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
assert pid_file.is_file(), "parent never spawned its child"
child_pid = int(pid_file.read_text())
deadline = _t.time() + 5.0
alive = True
while _t.time() < deadline:
out = sp.run(
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
).stdout
alive = str(child_pid) in out
if not alive:
break
_t.sleep(0.25)
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
def test_spawn_test_non_string_env_value(): def test_spawn_test_non_string_env_value():
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never # Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
# round-trip through the editor. Popen rejects non-str env; spawn_test must # round-trip through the editor. Popen rejects non-str env; spawn_test must
@@ -715,6 +906,68 @@ def test_args_secret_warning_empty():
assert c.args_secret_warning({"args": []}) is None assert c.args_secret_warning({"args": []}) is None
# --------------------------------------------------------------------------- #
# Named server sets (issue #52)
# --------------------------------------------------------------------------- #
def _three_servers():
return [
c.ServerEntry("alpha", {"command": "npx"}, True),
c.ServerEntry("beta", {"command": "uvx"}, True),
c.ServerEntry("gamma", {"url": "https://x.example/mcp"}, False),
]
def test_save_and_list_server_sets_roundtrip():
cfg: dict = {}
members = c.save_server_set(cfg, "webdev", _three_servers())
assert members == ["alpha", "beta"] # only the enabled ones
assert c.list_server_sets(cfg) == {"webdev": ["alpha", "beta"]}
def test_apply_server_set_enables_exactly_the_members():
servers = _three_servers()
missing = c.apply_server_set(servers, ["gamma"])
assert missing == []
assert [s.enabled for s in servers] == [False, False, True]
def test_apply_server_set_reports_missing_members():
servers = _three_servers()
missing = c.apply_server_set(servers, ["alpha", "vanished", "gone"])
assert missing == ["gone", "vanished"]
assert [s.enabled for s in servers] == [True, False, False] # rest still applied
def test_delete_server_set_drops_empty_key():
cfg: dict = {}
c.save_server_set(cfg, "only", _three_servers())
assert c.delete_server_set(cfg, "only") is True
assert c.SETS_KEY not in cfg # no empty bcc key left behind
assert c.delete_server_set(cfg, "only") is False
def test_server_sets_survive_write_and_reload(tmp_path):
cfgpath = tmp_path / "cfg.json"
cfg = {"mcpServers": {"alpha": {"command": "npx"}}}
c.save_server_set(cfg, "webdev", [c.ServerEntry("alpha", {"command": "npx"}, True)])
c.write_config(cfgpath, cfg)
reloaded = c.load_config(cfgpath)
assert c.list_server_sets(reloaded) == {"webdev": ["alpha"]}
def test_apply_servers_preserves_sets_key():
cfg: dict = {"mcpServers": {}}
c.save_server_set(cfg, "s", [c.ServerEntry("alpha", {"command": "npx"}, True)])
c.apply_servers(cfg, _three_servers())
assert c.SETS_KEY in cfg # the server writer never touches sets
def test_list_server_sets_skips_malformed_entries():
cfg = {c.SETS_KEY: {"good": ["a"], "bad-not-list": "a", "bad-items": ["a", 3]}}
assert c.list_server_sets(cfg) == {"good": ["a"]}
assert c.list_server_sets({c.SETS_KEY: "junk"}) == {}
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# resolve_name_collision (paste/import duplicate-name handling — issue #8) # resolve_name_collision (paste/import duplicate-name handling — issue #8)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -1135,8 +1388,12 @@ def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeyp
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def test_dunder_version_matches_pyproject(): def test_dunder_version_matches_pyproject():
# Guards against the version drifting out of sync between the two places # Guards against the version drifting out of sync between the two places
# a human might bump it. # a human might bump it. Reads pyproject.toml rather than hard-coding a
assert c.__version__ == "1.2.0" # version here (which would make this a third place to bump).
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
assert m, "no [project] version found in pyproject.toml"
assert c.__version__ == m.group(1)
def test_parse_version_basic(): def test_parse_version_basic():