30 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
the_og 6a91f830dc Merge pull request 'fix: only advance update/lastCheck after a completed check (#37)' (#44) from fix/37-update-lastcheck into main
CI / Lint (ruff) (push) Successful in 7s
CI / Tests (py3.10) (push) Successful in 9s
CI / Tests (py3.12) (push) Successful in 10s
2026-07-12 13:00:36 -04:00
Cowork Supervisor d95db2b026 fix: import every dropped .json (aggregate counts) + 5 MB size guard (#39)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10) (pull_request) Successful in 8s
CI / Tests (py3.12) (pull_request) Successful in 9s
Closes #39

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:46:59 -04:00
6 changed files with 716 additions and 35 deletions
+20 -4
View File
@@ -29,21 +29,37 @@ jobs:
run: ruff format --check .
test:
runs-on: ubuntu-latest
name: Tests (py${{ matrix.python }})
runs-on: ${{ matrix.os }}
name: Tests (py${{ matrix.python }} / ${{ matrix.os }})
strategy:
fail-fast: false
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:
- name: Checkout
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
with:
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 —
# keeps CI fast and avoids Qt system-library headaches on the runner.
- name: Install test dependencies
+235 -18
View File
@@ -19,9 +19,11 @@ from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, S
from PySide6.QtGui import (
QAction,
QColor,
QCursor,
QDesktopServices,
QGuiApplication,
QIcon,
QKeySequence,
QPainter,
QPixmap,
)
@@ -37,6 +39,7 @@ from PySide6.QtWidgets import (
QGridLayout,
QHBoxLayout,
QHeaderView,
QInputDialog,
QLabel,
QLineEdit,
QListWidget,
@@ -51,12 +54,17 @@ from PySide6.QtWidgets import (
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QToolTip,
QVBoxLayout,
QWidget,
)
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 --------------- #
ACCENT = "#f97316" # warm orange
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::add-line, QScrollBar::sub-line {{ height: 0; }}
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#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; }}
@@ -266,10 +275,31 @@ class KeyValueTable(QWidget):
self.reveal_btn.setText("Hide secrets" if on else "Show secrets")
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:
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):
dlg = QDialog(self.window())
dlg.setWindowTitle(f"Add {self._key_label}")
@@ -296,6 +326,13 @@ class KeyValueTable(QWidget):
grid.addWidget(val_edit, 1, 1)
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.
def _sync_echo(text):
secret = core.is_secret_key(text)
@@ -311,7 +348,15 @@ class KeyValueTable(QWidget):
ok_btn = btns.button(QDialogButtonBox.StandardButton.Ok)
ok_btn.setObjectName("primary")
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.rejected.connect(dlg.reject)
v.addWidget(btns)
@@ -325,13 +370,15 @@ class KeyValueTable(QWidget):
return
k = key_edit.text().strip()
val = val_edit.text()
if not k:
if not k or self._is_duplicate_key(k, ignore_row=-1):
return
if self._before_change:
self._before_change()
r = self.table.rowCount()
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._changed()
@@ -350,7 +397,9 @@ class KeyValueTable(QWidget):
for k, v in (d or {}).items():
r = self.table.rowCount()
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.blockSignals(False)
@@ -1492,6 +1541,15 @@ class MainWindow(QMainWindow):
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.
split = QSplitter(Qt.Orientation.Horizontal)
split.setChildrenCollapsible(False)
@@ -1543,14 +1601,16 @@ class MainWindow(QMainWindow):
last = float(st.value("update/lastCheck", 0.0, type=float) or 0.0)
if (time.time() - last) < 86400: # at most once/day
return
st.setValue("update/lastCheck", time.time())
self._startup_update_worker = UpdateCheckWorker()
self._startup_update_worker.done.connect(self._on_startup_update_checked)
self._startup_update_worker.start()
def _on_startup_update_checked(self, release: dict | None):
self._startup_update_worker = None
if release and core.is_newer_version(core.__version__, release["version"]):
if release is None:
return # offline/failed check: don't advance lastCheck, allow retry
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
if core.is_newer_version(core.__version__, release["version"]):
self.status.setText(
f"Update available: {release['version']} · Help ▸ About to view it."
)
@@ -1628,11 +1688,45 @@ class MainWindow(QMainWindow):
head.setObjectName("h1")
v.addWidget(head)
search_row = QHBoxLayout()
self.search_box = QLineEdit()
self.search_box.setPlaceholderText("Search servers by name, command, or url…")
self.search_box.setClearButtonEnabled(True)
self.search_box.textChanged.connect(self._on_search_changed)
v.addWidget(self.search_box)
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
# can drag the divider instead of being stuck with a fixed-height
@@ -1716,6 +1810,12 @@ class MainWindow(QMainWindow):
undo_action.setShortcut("Ctrl+Z")
undo_action.triggered.connect(self._undo)
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()
self.validation_lbl = QLabel("")
bar.addWidget(self.validation_lbl)
@@ -1742,6 +1842,19 @@ class MainWindow(QMainWindow):
self._mark_dirty()
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 -------------------------------------------------------- #
def reload_profiles(self):
discovered = core.discover_profiles()
@@ -1764,8 +1877,9 @@ class MainWindow(QMainWindow):
def _maybe_warn_msix(self):
"""
Windows-only, no-op everywhere else: if Claude Desktop looks like an
MSIX/Store install with a virtualized config, append a warning to the
status bar so edits to the plain %APPDATA% path aren't silently lost.
MSIX/Store install with a virtualized config, show a persistent banner
so edits to the plain %APPDATA% path aren't silently lost. (The status
bar is the wrong home for this: it's rewritten on every action.)
Defensive on purpose -- this must never block startup or profile load.
"""
try:
@@ -1773,8 +1887,11 @@ class MainWindow(QMainWindow):
except Exception:
return
if warning:
self.status.setText(f"{self.status.text()} {warning}")
self.status.setToolTip(warning)
self.warn_banner.setText(f" {warning}")
self.warn_banner.setToolTip(warning)
self.warn_banner.show()
else:
self.warn_banner.hide()
def add_custom_config(self):
start = str(core.app_support_base())
@@ -1830,6 +1947,7 @@ class MainWindow(QMainWindow):
self._undo_stack.clear()
self.undo_btn.setEnabled(False)
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._update_status(saved=False)
if repaired:
@@ -1929,6 +2047,73 @@ class MainWindow(QMainWindow):
cur = self._current_index()
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) ---------------- #
def _test_all_servers(self):
targets = [s for s in self.servers if s.enabled and s.kind == "stdio"]
@@ -2077,12 +2262,17 @@ class MainWindow(QMainWindow):
if not (0 <= idx < len(self.servers)):
return
entry = self.servers[idx]
old_name = entry.name
entry.name = self.editor.current_name()
entry.data = self.editor.dump_data()
# The server stays in its section (enable state unchanged), so update
# its existing row in place rather than re-rendering.
# An edit invalidates any cached "Test all" result -- the server that
# was spawn-tested no longer matches what's on disk once saved.
# 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)
loc = self._row_of_index.get(idx)
if loc:
@@ -2242,11 +2432,23 @@ class MainWindow(QMainWindow):
self.validation_lbl.setStyleSheet(f"color: {WARN};")
self.save_btn.setEnabled(False)
return False
lint_warnings = core.lint_servers(self.servers)
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)
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):
if not self.current_profile:
return
@@ -2400,29 +2602,44 @@ class MainWindow(QMainWindow):
e.acceptProposedAction()
def dropEvent(self, e):
total_added, total_replaced, files_imported = 0, 0, 0
undo_pushed = False
for u in e.mimeData().urls():
path = u.toLocalFile()
if not path.endswith(".json"):
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:
servers = core.parse_pasted_json(text)
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
if not undo_pushed:
self._push_undo()
undo_pushed = True
added, replaced = 0, 0
for name, data in servers.items():
a, r = self._import_server(name, data)
added += int(a)
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._mark_dirty()
self.status.setText(
f"Imported {added} added, {replaced} replaced from {Path(path).name}. "
"Review and Save."
f"Imported {total_added} added, {total_replaced} replaced from "
f"{files_imported} file(s). Review and Save."
)
break
def closeEvent(self, e):
if self.dirty and not self._confirm_discard():
+2 -2
View File
@@ -78,8 +78,8 @@ if sys.platform == "darwin":
info_plist={
"CFBundleName": "Better Claude Config",
"CFBundleDisplayName": "Better Claude Config",
"CFBundleShortVersionString": "1.0.0",
"CFBundleVersion": "1.0.0",
"CFBundleShortVersionString": "1.3.0",
"CFBundleVersion": "1.3.0",
"NSHighResolutionCapable": True,
"NSRequiresAquaSystemAppearance": False, # supports dark mode
"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.
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"
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)
# 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"
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]:
"""
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*`
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
be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is
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()
out: list[Profile] = []
@@ -319,6 +362,12 @@ def discover_profiles() -> list[Profile]:
cc_cfg = home / ".claude.json"
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
# ~/.claude/settings.json, where Claude Code ignores them. Surface that
# 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
# --------------------------------------------------------------------------- #
# 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`.
@@ -1213,6 +1326,63 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]:
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
# --------------------------------------------------------------------------- #
@@ -1581,6 +1751,23 @@ def server_log_path(name: str) -> Path | None:
_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:
"""
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":
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:
proc = subprocess.Popen(args_list, **popen_kwargs)
@@ -1696,7 +1887,7 @@ def _spawn_test_impl(data: dict, timeout: float) -> dict:
if os.name != "nt":
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL)
else:
proc.kill() # best-effort on Windows
_kill_process_tree_windows(proc.pid)
except OSError:
pass
with contextlib.suppress(subprocess.TimeoutExpired):
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
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"
readme = "README.md"
license = { file = "LICENSE" }
+261 -4
View File
@@ -3,9 +3,11 @@ proper test functions with tmp_path/monkeypatch fixtures)."""
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
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())
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
# --------------------------------------------------------------------------- #
@@ -179,6 +239,85 @@ def test_valid_set_passes_clean():
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
# --------------------------------------------------------------------------- #
@@ -227,8 +366,11 @@ def test_diagnostics_redacts_token_args():
# --------------------------------------------------------------------------- #
# 6. Dependency / PATH checking
# --------------------------------------------------------------------------- #
def test_dep_check_finds_python3():
assert c.check_dependency({"command": "python3"})["status"] == "ok"
def test_dep_check_finds_python():
# "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():
@@ -326,6 +468,55 @@ def test_spawn_test_large_stderr_does_not_deadlock():
assert len(r["stderr"]) <= c._STDERR_CAP
def test_windows_tree_kill_uses_taskkill(monkeypatch):
"""The Windows kill path must walk the process tree (taskkill /T /F);
Popen.kill() alone orphans grandchildren (issue #13)."""
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
return _FakeRC()
class _FakeRC:
returncode = 0
monkeypatch.setattr(c.subprocess, "run", fake_run)
c._kill_process_tree_windows(1234)
assert calls == [["taskkill", "/PID", "1234", "/T", "/F"]]
@pytest.mark.skipif(os.name != "nt", reason="Windows-only: real process-tree kill")
def test_spawn_test_windows_kills_child_process_tree(tmp_path):
"""A spawn-tested parent that has its own child (npx -> node style) must
not leave the child running after the timeout kill (issue #13)."""
import subprocess as sp
import time as _t
pid_file = tmp_path / "child.pid"
parent_script = (
"import subprocess, sys, time\n"
"p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n"
f"open({str(pid_file)!r}, 'w').write(str(p.pid))\n"
"time.sleep(60)\n"
)
r = c.spawn_test({"command": sys.executable, "args": ["-c", parent_script]}, timeout=2.0)
assert r["outcome"] == "ok" # parent was still running at timeout, then tree-killed
assert pid_file.is_file(), "parent never spawned its child"
child_pid = int(pid_file.read_text())
deadline = _t.time() + 5.0
alive = True
while _t.time() < deadline:
out = sp.run(
["tasklist", "/FI", f"PID eq {child_pid}"], capture_output=True, text=True
).stdout
alive = str(child_pid) in out
if not alive:
break
_t.sleep(0.25)
assert not alive, f"child pid {child_pid} survived the spawn-test kill"
def test_spawn_test_non_string_env_value():
# Pasted JSON can carry numeric env values ("env": {"PORT": 8080}) that never
# round-trip through the editor. Popen rejects non-str env; spawn_test must
@@ -715,6 +906,68 @@ def test_args_secret_warning_empty():
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)
# --------------------------------------------------------------------------- #
@@ -1135,8 +1388,12 @@ def test_restart_claude_desktop_linux_refuses_without_touching_processes(monkeyp
# --------------------------------------------------------------------------- #
def test_dunder_version_matches_pyproject():
# Guards against the version drifting out of sync between the two places
# a human might bump it.
assert c.__version__ == "1.2.0"
# a human might bump it. Reads pyproject.toml rather than hard-coding a
# version here (which would make this a third place to bump).
text = (Path(__file__).parent.parent / "pyproject.toml").read_text(encoding="utf-8")
m = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE)
assert m, "no [project] version found in pyproject.toml"
assert c.__version__ == m.group(1)
def test_parse_version_basic():