Author SHA1 Message Date
the_og 072a5cdc08 Merge PR #86: disambiguate project profiles with the same basename (#74)
CI / Lint (ruff) (push) Successful in 12s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.12 / windows-latest) (push) Successful in 35s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 18s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 18s
CI / Catalog signature (push) Successful in 14s
Two repos both named "app" no longer both render as "Project: app": disambiguate_project_labels widens colliding labels toward the root (Project: work/app vs personal/app), discover_project_configs now requires a .mcp.json that parses to a dict (skipping arrays/garbage that only failed on open), and the full path goes in the combo tooltip. Pure core + 6 tests; the only GUI change is one setItemData line. 463 passed, ruff clean, CI green incl. Windows.

Closes #74
2026-08-03 23:42:18 -04:00
the_ogandClaude Opus 4.8 0191a93eb9 fix: disambiguate project profiles that share a directory basename (#74)
CI / Lint (ruff) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 19s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 19s
CI / Catalog signature (pull_request) Successful in 11s
discover_project_configs labelled every project by basename alone, so
~/work/app/.mcp.json and ~/personal/app/.mcp.json both read as
"Project: app". Paths dedupe correctly, so both profiles existed -- they
were just indistinguishable in the picker, and picking the wrong one meant
editing, backing up, and writing the wrong repo's config. api/web/app/
server/client as repo names make this common.

- disambiguate_project_labels(dirs): pure, testable. Labels stay
  "Project: <name>" until a basename collides, then only the colliding
  ones widen toward the root one component at a time
  ("Project: work/app" vs "Project: personal/app"), widening further if the
  parent also collides. The common no-collision case is unchanged.
- Full path goes in the combo item's ToolTipRole, so a profile is always
  verifiable by hover regardless of label.
- Tightened the "is this a project config?" check: the docstring claimed a
  top-level object but the code only checked is_file(), so a .mcp.json that
  was a JSON array or garbage still became a profile and only failed on
  open. It now must parse (strict) to a dict, else it's skipped.

Tests: +6 (no-collision basename, colliding widen, deeper widen, and
discover_project_configs disambiguation + skipping array/garbage/missing).
463 passed, ruff clean.

Closes #74

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
2026-08-04 03:29:13 +00:00
the_og 5add9b0ce0 Merge PR #85: ClientSpec adapter refactor — cross-client phase 1 (#5)
CI / Lint (ruff) (push) Successful in 21s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 24s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 25s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 30s
CI / Tests (py3.12 / windows-latest) (push) Successful in 25s
CI / Catalog signature (push) Successful in 24s
Phase 1 of cross-client support: introduce the ClientSpec adapter and route Claude Desktop + Claude Code through it with no behavior change. Parameterizes servers_key/disabled_key/discovery and the entry translation seam; extract_servers/apply_servers/_server_sections/external_change_summary default to Claude's layout so no-spec calls are byte-identical. Verified: 458 tests + a synthetic non-mcpServers client proving the seam generalizes, CI green on all platforms incl. Windows, and the live GUI confirmed loading/saving identically. requirements.txt now declares cryptography as the runtime dep it always was.

Refs #5
2026-08-03 23:19:56 -04:00
the_ogandClaude Opus 4.8 57fd3cb6e3 fix: declare cryptography as a runtime dependency
CI / Lint (ruff) (pull_request) Successful in 24s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 38s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 28s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 31s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 28s
CI / Catalog signature (pull_request) Successful in 19s
bcc_core imports cryptography at module load (catalog signature
verification), so it's required to even start the app -- but
requirements.txt listed only PySide6, so a from-source run died with
ModuleNotFoundError: No module named 'cryptography'. The frozen release
builds were unaffected because PyInstaller follows the import, which is
why this never surfaced until someone ran the GUI from source to review
this branch.

Move cryptography into requirements.txt (runtime) and re-label it in
requirements-dev.txt as runtime rather than test-only; the version pin is
unchanged (>=42.0).

Refs #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
2026-08-04 03:17:09 +00:00
the_ogandClaude Opus 4.8 4a95b370b9 refactor: introduce ClientSpec adapter; route Claude Desktop + Code through it (#5)
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 24s
CI / Lint (ruff) (pull_request) Successful in 21s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 32s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 31s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 35s
CI / Catalog signature (pull_request) Successful in 26s
Cross-client support (Cursor / Windsurf / VS Code) was blocked on Claude's
layout being hard-coded throughout the code: servers always under the literal
"mcpServers" key, a fixed set of Claude file locations, and "which client is
this?" answered by sniffing a filename. Adding a client that differs on any of
those axes meant chasing those assumptions through a dozen sites.

This is phase 1 of #5: the keystone refactor, with NO behaviour change. It adds
a ClientSpec adapter that captures the three things that vary across clients --
the top-level servers key, the config discovery paths, and the per-server value
shape -- plus the capability flags that were previously computed inline from a
filename (does the client expand ${VAR}? can we offer Restart?).

- ClientSpec (frozen dataclass): servers_key, disabled_key, config_filename,
  expands_env_refs, supports_restart, and entry_to_internal/entry_from_internal
  -- the per-server translation seam, identity for any mcpServers-shaped client,
  the single point a differently-shaped client (VS Code's type/inputs form)
  overrides.
- CLAUDE_DESKTOP and CLAUDE_CODE specs; both use mcpServers + the existing
  parking key, so their translation is the identity and nothing changes for
  today's users. resolve_client(path) reproduces the old filename rule exactly;
  each Profile now carries its resolved .client.
- extract_servers / apply_servers / _server_sections / external_change_summary
  take an optional spec and default to Claude's layout, so every existing call
  site and test that omits a spec is byte-for-byte unchanged. The cardinal rule
  now generalises: apply_servers only ever writes the client's own two keys,
  parameterised rather than hard-coded.
- profile_targets_claude_desktop and client_expands_env_refs are now thin reads
  off the profile's spec -- one source of truth for client identity instead of
  scattered filename checks -- with identical answers.
- GUI: the load, Copy-to, save and stale-merge paths pass the profile's spec
  into the core calls. Mechanical; no logic moved into bcc.py (which CI can't
  test -- no PySide6).

Tests: +14. Existing suite unchanged and green (behaviour preservation). A
synthetic non-mcpServers spec ("servers" key, a different disabled key, a
per-server `type` field) exercises the whole pipeline -- extract, apply,
masking, external-change diff -- proving the seam actually generalises before
any real client depends on it. 458 passed, 1 skipped; ruff clean.

Refs #5

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
2026-08-04 02:53:51 +00:00
the_og f168079755 Merge PR #82: author ${VAR} references, gated on client expansion (#76)
CI / Tests (py3.12 / windows-latest) (push) Successful in 39s
CI / Lint (ruff) (push) Successful in 5m0s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 39s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 35s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 34s
CI / Catalog signature (push) Successful in 25s
Adds the authoring + safety layer for ${VAR} references: expand_env_refs preview, per-client gating via profile_targets_claude_desktop (Claude Code expands natively, Desktop does not), and fixes two backwards behaviours (masking hid placeholders; args_secret_warning fired on the recommended fix). BCC never expands on write. 444 tests, ruff clean.

Closes #76
2026-08-03 22:41:06 -04:00
the_og a73f2e3883 feat: author ${VAR} references, gated on whether the client expands them (#76)
CI / Lint (ruff) (pull_request) Successful in 9s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
The blocker on this issue was whether BCC or the client does the expanding.
Answer, from Anthropic's docs: Claude Code expands ${VAR} and
${VAR:-default} itself, in command, args, env, url and headers, for both
project .mcp.json and user-scope ~/.claude.json. Claude Desktop has no
documented support.

So this is a per-client capability, not a global one, and BCC does NOT
expand on write: resolving a reference into the file would put the secret
back on disk -- the whole thing the user is avoiding -- and would defeat a
feature the client already implements correctly. BCC authors, validates and
warns; expand_env_refs exists to preview what the client will do.

Semantics mirror the documented ones exactly, including the unusual bit:
an unset variable with no default is left as literal ${VAR} text rather
than blanked, because that is what Claude Code passes through.

Gating uses the existing profile_targets_claude_desktop(), so a config that
is correct under Claude Code and broken under Desktop is reported against
whichever profile is actually loaded. The two warnings are worded
differently on purpose -- 'this client will never expand these' is a
different problem from 'this variable looks unset here'.

Two existing behaviours were backwards for this feature and are fixed:

- Secret masking hid placeholders. is_secret_key('API_KEY') is true, so
  ${API_KEY} rendered as dots -- making a reference indistinguishable from
  a stored credential, which is the one distinction that makes the feature
  worth adopting. should_mask_value() now skips references, in the table
  delegate, _redact_server_data and redact_args alike.
- args_secret_warning fired on placeholders. Moving a token into ${VAR} is
  the recommended fix for that warning; continuing to warn punished the
  fix. It now skips references while still flagging a real secret that
  follows one.

Real secrets are still masked everywhere they were before -- asserted, not
assumed.

Refs #76
2026-07-20 13:46:14 -04:00
the_og 7ff4f6e5c0 Merge PR #81: make the update checker visible — persistent banner + Help menu item (#78, #79)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 6s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:52:48 -04:00
the_og 7517e16b15 Merge branch 'main' into fix/78-79-update-visibility
CI / Lint (ruff) (pull_request) Successful in 6s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Three conflicts, two of them semantic rather than textual:

- bcc.py QSS: this branch added the noticeBanner rules using the old
  module-level constants ({MUTED}, {ACCENT}); main had since moved the
  stylesheet onto palette slots ({p.muted}). Took main's form and
  translated the notice rules into it -- picking either side wholesale
  would have either dropped the banner styling or reintroduced globals
  that test_stylesheet_builder_has_no_hardcoded_colours now forbids.
- bcc.py methods: both sides appended to MainWindow (update-notice
  handlers vs theme handlers). Additive, kept both.
- tests/test_core.py: the usual EOF append. Kept both blocks.

_build_menu_bar auto-merged cleanly (View menu above, Help menu below);
verified both are present with their menu roles intact.

Verified: 272 test functions = 265 (main) + 7 (this branch), no
duplicates; 421 passed, ruff clean.
2026-07-20 12:51:55 -04:00
the_og 9a0433225e Merge PR #80: light theme + system-following, dark preserved exactly (#75)
CI / Lint (ruff) (push) Successful in 6s
CI / Tests (py3.10 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.12 / ubuntu-latest) (push) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 10s
CI / Catalog signature (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:51:01 -04:00
the_og fa82d30087 Merge branch 'main' into feat/75-theming
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 7s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Union conflict at the end of tests/test_core.py -- both branches appended a
test block. Kept both, main's #72/#73 block first. Verified: 265 test
functions = 238 baseline + 15 (#77) + 12 (theming), no duplicates.
2026-07-20 12:50:09 -04:00
the_og 05b00a40c0 Merge PR #77: tolerate non-object server values; keep named sets across a merge (#72, #73)
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 10s
CI / Tests (py3.13 / ubuntu-latest) (push) Successful in 11s
CI / Catalog signature (push) Successful in 7s
CI / Tests (py3.12 / windows-latest) (push) Has been cancelled
2026-07-20 12:49:23 -04:00
the_og 3068e74e5c fix: make the update checker visible -- persistent banner + a menu item
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 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 10s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Reported from the field: running v1.2 against a repo with v1.3.0 published
gave no prompt, and there appeared to be no way to check manually. The
checker itself works; it was invisible, for two reasons.

#78 -- the notice was written to the shared status label, which 21 other
call sites rewrite. The check runs off-thread and lands a second or two
after launch, right as the user starts clicking, so the next selection or
refresh wiped it. Exactly the bug fixed for the MSIX warning in #35, which
got a persistent banner; that fix was never carried to the update notice.

Adds NoticeBanner: a persistent, dismissible notice carrying its own action
button. It's a shared widget rather than a second bespoke banner, so the
next thing needing the user's attention doesn't reach for the status bar
again. (The MSIX banner still uses its own QLabel -- migrating it is a
follow-up, deliberately not bundled with a bug fix.)

#79 -- the only 'Check for updates' affordance was a button inside the
About dialog, which is not where anyone looks. Worse, the About action was
created without a menu role, and Qt auto-assigns AboutRole to actions whose
text begins with 'About', relocating it into the macOS application menu --
so the notice's own hint, 'Help > About to view it', pointed at a menu that
on macOS doesn't contain the item.

Help now has its own 'Check for updates...' item with an explicit
ApplicationSpecificRole, and the About action states its AboutRole rather
than inheriting it invisibly. The menu-driven check is never throttled and
always reports back -- the user asked, so silence would read as broken.

The decision and the wording live in core.update_notice() because the test
suite has no PySide6 (CI installs pytest + cryptography only), so anything
in bcc.py is untestable. A test asserts the notice text names no menu path,
which is what went stale here in the first place.

Closes #78
Closes #79
2026-07-20 12:29:37 -04:00
the_og febd617c56 feat: light theme + system-following, with the dark theme preserved exactly
CI / Lint (ruff) (pull_request) Successful in 8s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 10s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 6s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
BCC has always been dark-only -- BG #1b1d23, hardcoded at import time, with
no light option and no awareness of the desktop's appearance. On a light
desktop it matches nothing else on screen and there was no way to change it.

Adds a Palette value type in bcc_core with DARK (byte-identical to the
colours v1.3.0 shipped) and a new LIGHT, plus resolve_theme(setting,
system_is_dark) so the decision is testable without a Qt app. View > Theme
offers Match system / Light / Dark, persisted in QSettings under ui/theme,
defaulting to following the system.

The light palette's semantic colours are deliberately not the dark ones
lightened: #4ade80 sits near 1.7:1 against white. They are darkened to clear
WCAG AA, and a contrast test enforces >= 4.5:1 for every text colour against
its surface in both palettes so nobody harmonises them back later.

Three near-black literals were baked into the stylesheet (#1a1205 on-accent
text, #202229 disabled table, #16181d diagnostics pane). Fine with one theme,
invisible breakage with two -- each now has a palette slot, and a test
asserts build_stylesheet contains no hex literals at all.

The ~20 inline setStyleSheet(f"color: {MUTED}") call sites are left alone:
apply_palette rebinds the module-level colour names, and an f-string resolves
its names when it runs, so each call site picks up the new colour on its next
render. Switching theme reapplies the global QSS and re-renders the
inline-styled widgets, so nothing is left dark-on-light.

Refs #75
2026-07-20 12:22:38 -04:00
the_og da20eb2fdb fix: tolerate non-object server values on load; keep named sets across a merge
CI / Lint (ruff) (pull_request) Successful in 13s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 12s
CI / Catalog signature (pull_request) Successful in 8s
CI / Tests (py3.12 / windows-latest) (pull_request) Has been cancelled
Two silent-failure bugs found auditing the v1.3.0 features.

#72 -- extract_servers called dict() on every server value, so a config
that was valid JSON but held a non-object server ("foo": "oops", a
number, a list, null) raised on load. The strict parse succeeded, so the
repair path never saw it, and the call sat outside the load try/except:
an unhandled traceback with the window half-swapped to the new profile.
It also made the #54 schema lint unreachable for the most likely
hand-edit mistake -- the load died before the linter ran.

Malformed values are now preserved verbatim on ServerEntry.raw (behind a
NO_RAW sentinel, since a literal JSON null is itself a malformed entry
worth keeping) and written back untouched on Save, so nothing is silently
deleted. lint_servers names the offending entry instead.

#73 -- the stale-file "Merge & save" path reloaded the file from disk and
re-applied the user's servers, but apply_servers only writes mcpServers
and _disabledMcpServers. Named server sets live under _bccServerSets in
the same file, so a set saved that session was dropped from disk and then
from memory, with no warning, on the path the user picks because it
sounds like the safe one.

BCC-owned keys are now declared in BCC_OWNED_KEYS and carried across by
carry_owned_keys, which reports genuinely contested keys so the status
line can say so. Deliberately one-directional: a key absent locally is
left alone on disk, because 'user deleted their last set' and 'another
machine just added sets' are indistinguishable and deleting someone
else's data is the worse failure.

Closes #72
Closes #73
2026-07-20 12:11:49 -04:00
6 changed files with 1709 additions and 107 deletions
+3 -1
View File
@@ -31,6 +31,8 @@ The codebase is split into two layers:
**`bcc_core.py`** — All logic with no GUI imports. Contains: **`bcc_core.py`** — All logic with no GUI imports. Contains:
- `Profile` / `ServerEntry` dataclasses (the data model) - `Profile` / `ServerEntry` dataclasses (the data model)
- `ClientSpec` (issue #5, cross-client) — one adapter object per MCP host capturing everything client-specific: the top-level `servers_key` (Claude uses `mcpServers`; VS Code will use `servers`), the parking `disabled_key`, config `config_filename`, the capability flags (`expands_env_refs`, `supports_restart`), and a per-server `entry_to_internal`/`entry_from_internal` translation pair (identity for Claude; the seam a differently-shaped client overrides). `CLAUDE_DESKTOP` and `CLAUDE_CODE` are the two shipped specs; `resolve_client(path)` picks one by filename, and each `Profile` carries its resolved `client`. The read/write/diff functions take an optional `spec` and default to Claude's layout, so a call with no spec is unchanged.
- `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config… - `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude.json` (Claude Code user scope — what `claude mcp add` writes). `~/.claude/settings.json` is NOT a server config (it rejects `mcpServers` with a schema error) and is only surfaced, labelled legacy, if servers are found parked in it. Project-scope `.mcp.json` files can be opened via Add config…
- `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/` - `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/`
- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog) - `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog)
@@ -43,7 +45,7 @@ The codebase is split into two layers:
- `KeyValueTable` — reusable widget for env vars and headers - `KeyValueTable` — reusable widget for env vars and headers
- `ConnTester(QThread)` — background thread for remote reachability tests - `ConnTester(QThread)` — background thread for remote reachability tests
**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order. **The cardinal rule**: `apply_servers()` only ever writes the two keys the target client's servers live under — by default `mcpServers` and `_disabledMcpServers`, or whatever the profile's `ClientSpec` declares (`servers_key` + `disabled_key`). All other keys in the user's config are preserved verbatim and in their original order. The rule generalises across clients precisely because it is parameterised by the spec rather than hard-coded.
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition. Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
+302 -65
View File
@@ -10,6 +10,7 @@ Run: python mcp_manager.py
from __future__ import annotations from __future__ import annotations
import contextlib
import sys import sys
import time import time
from pathlib import Path from pathlib import Path
@@ -18,6 +19,7 @@ from typing import ClassVar
from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal from PySide6.QtCore import QRect, QSettings, QSize, Qt, QThread, QTimer, QUrl, Signal
from PySide6.QtGui import ( from PySide6.QtGui import (
QAction, QAction,
QActionGroup,
QColor, QColor,
QCursor, QCursor,
QDesktopServices, QDesktopServices,
@@ -25,6 +27,7 @@ from PySide6.QtGui import (
QIcon, QIcon,
QKeySequence, QKeySequence,
QPainter, QPainter,
QPalette,
QPixmap, QPixmap,
) )
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
@@ -65,85 +68,122 @@ import bcc_core as core
# thread during drag-and-drop import, so skip anything larger than this. # thread during drag-and-drop import, so skip anything larger than this.
MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB MAX_DROP_IMPORT_BYTES = 5 * 1024 * 1024 # 5 MB
# --- One-line rebrand: change this to recolor the whole app --------------- # # --- Theming (issue #75) -------------------------------------------------- #
ACCENT = "#f97316" # warm orange # The palette lives in bcc_core (testable without a Qt app); these module-level
ACCENT_DIM = "#c2570b" # names are rebound by `apply_palette()` whenever the theme changes.
BG = "#1b1d23" #
PANEL = "#23262e" # Why globals rather than passing a palette around: ~20 inline
PANEL_2 = "#2b2f39" # `setStyleSheet(f"color: {MUTED}")` calls are scattered through this file, and
TEXT = "#e7e9ee" # an f-string resolves its names when it runs, not when it's compiled. Rebinding
MUTED = "#9aa0ad" # the globals means every one of those call sites picks up the new colour on its
BORDER = "#3a3f4b" # next render, with no change to the call sites themselves.
GOOD = "#4ade80" PALETTE = core.DARK_PALETTE
BAD = "#f87171" ACCENT = ACCENT_DIM = BG = PANEL = PANEL_2 = TEXT = MUTED = BORDER = ""
WARN = "#fbbf24" GOOD = BAD = WARN = REMOTE = ON_ACCENT = DISABLED_BG = MONO_BG = SEL_TEXT = ""
STATUS_COLORS: dict[str, str] = {}
HEALTH_COLORS: dict[str, str] = {}
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": "#60a5fa", "unknown": WARN} STATUS_GLYPH = {
STATUS_GLYPH = {"ok": "●", "missing": "●", "warn": "▲", "remote": "◆", "unknown": "○"} "ok": "\u25cf",
"missing": "\u25cf",
"warn": "\u25b2",
"remote": "\u25c6",
"unknown": "\u25cb",
}
# Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the # Health dot (spawn-test outcome, see core.HealthStatus) shown per row in the
# server tables' "Health" column -- distinct from the PATH-dependency Status # server tables' "Health" column -- distinct from the PATH-dependency Status
# column above. # column above.
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED} HEALTH_GLYPH = {"ok": "\u25cf", "failed": "\u25cf", "untested": "\u25cb"}
HEALTH_GLYPH = {"ok": "●", "failed": "●", "untested": "○"}
STYLESHEET = f"""
def build_stylesheet(p: core.Palette) -> str:
"""Render the global QSS for a palette."""
return f"""
/* No font-family here on purpose: Qt already uses the native system UI font /* No font-family here on purpose: Qt already uses the native system UI font
on every platform (San Francisco / Segoe UI / desktop default). Naming on every platform (San Francisco / Segoe UI / desktop default). Naming
web-CSS aliases like -apple-system forces a costly font-alias scan. */ web-CSS aliases like -apple-system forces a costly font-alias scan. */
* {{ font-size: 13px; color: {TEXT}; }} * {{ font-size: 13px; color: {p.text}; }}
QMainWindow, QDialog {{ background: {BG}; }} QMainWindow, QDialog {{ background: {p.bg}; }}
QLabel#h1 {{ font-size: 15px; font-weight: 600; }} QLabel#h1 {{ font-size: 15px; font-weight: 600; }}
QLabel#muted {{ color: {MUTED}; }} QLabel#muted {{ color: {p.muted}; }}
QFrame#card {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; }} QFrame#card {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px; }}
QLineEdit, QPlainTextEdit, QComboBox {{ QLineEdit, QPlainTextEdit, QComboBox {{
background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 6px 8px; selection-background-color: {ACCENT}; selection-color: #1a1205; padding: 6px 8px; selection-background-color: {p.accent}; selection-color: {p.on_accent};
}} }}
QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {ACCENT}; }} QLineEdit:focus, QPlainTextEdit:focus, QComboBox:focus {{ border: 1px solid {p.accent}; }}
QComboBox::drop-down {{ border: none; width: 22px; }} QComboBox::drop-down {{ border: none; width: 22px; }}
QComboBox QAbstractItemView {{ background: {PANEL_2}; border: 1px solid {BORDER}; QComboBox QAbstractItemView {{ background: {p.panel_2}; border: 1px solid {p.border};
selection-background-color: {ACCENT}; outline: none; }} selection-background-color: {p.accent}; outline: none; }}
QPushButton {{ background: {PANEL_2}; border: 1px solid {BORDER}; border-radius: 7px; QPushButton {{ background: {p.panel_2}; border: 1px solid {p.border}; border-radius: 7px;
padding: 7px 13px; }} padding: 7px 13px; }}
QPushButton:hover {{ border: 1px solid {ACCENT}; }} QPushButton:hover {{ border: 1px solid {p.accent}; }}
QPushButton:disabled {{ color: {MUTED}; background: {PANEL}; }} QPushButton:disabled {{ color: {p.muted}; background: {p.panel}; }}
QPushButton#primary {{ background: {ACCENT}; border: 1px solid {ACCENT}; color: #1a1205; font-weight: 600; }} QPushButton#primary {{ background: {p.accent}; border: 1px solid {p.accent}; color: {p.on_accent}; font-weight: 600; }}
QPushButton#primary:hover {{ background: {ACCENT_DIM}; }} QPushButton#primary:hover {{ background: {p.accent_dim}; }}
QPushButton#primary:disabled {{ background: {PANEL}; color: {MUTED}; border: 1px solid {BORDER}; }} QPushButton#primary:disabled {{ background: {p.panel}; color: {p.muted}; border: 1px solid {p.border}; }}
QPushButton#danger:hover {{ border: 1px solid {BAD}; color: {BAD}; }} QPushButton#danger:hover {{ border: 1px solid {p.bad}; color: {p.bad}; }}
QTableWidget {{ background: {PANEL}; border: 1px solid {BORDER}; border-radius: 10px; QTableWidget {{ background: {p.panel}; border: 1px solid {p.border}; border-radius: 10px;
gridline-color: transparent; outline: none; }} gridline-color: transparent; outline: none; }}
QTableWidget::item {{ padding: 6px 8px; border: none; }} QTableWidget::item {{ padding: 6px 8px; border: none; }}
QTableWidget::item:selected {{ background: {ACCENT}; color: #1a1205; }} QTableWidget::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
/* Inline cell editors: the global QLineEdit padding/radius clips the text /* Inline cell editors: the global QLineEdit padding/radius clips the text
inside a table row, so give editors a compact, flat style instead. */ inside a table row, so give editors a compact, flat style instead. */
QTableWidget QLineEdit {{ QTableWidget QLineEdit {{
background: {PANEL_2}; color: {TEXT}; border: 1px solid {ACCENT}; background: {p.panel_2}; color: {p.text}; border: 1px solid {p.accent};
border-radius: 3px; padding: 0px 4px; margin: 0px; border-radius: 3px; padding: 0px 4px; margin: 0px;
selection-background-color: {ACCENT_DIM}; selection-color: #ffffff; selection-background-color: {p.accent_dim}; selection-color: {p.selection_text};
}} }}
QHeaderView::section {{ background: {PANEL}; color: {MUTED}; border: none; QHeaderView::section {{ background: {p.panel}; color: {p.muted}; border: none;
border-bottom: 1px solid {BORDER}; padding: 8px; font-weight: 600; }} border-bottom: 1px solid {p.border}; padding: 8px; font-weight: 600; }}
QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 2px; }}
QScrollBar::handle:vertical {{ background: {BORDER}; border-radius: 5px; min-height: 24px; }} QScrollBar::handle:vertical {{ background: {p.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: {p.muted}; padding: 4px 2px; }}
QLabel#warnBanner {{ color: #1a1205; background: {WARN}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }} QLabel#warnBanner {{ color: {p.on_accent}; background: {p.warn}; border-radius: 8px; padding: 8px 10px; font-weight: 600; }}
QLabel#section {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QFrame#noticeBanner {{ background: {p.panel_2}; border: 1px solid {p.accent}; border-radius: 8px; }}
QLabel#sectionDisabled {{ color: {MUTED}; font-weight: 600; font-size: 12px; padding: 2px 2px; }} QLabel#noticeText {{ color: {p.text}; }}
QLabel#placeholder {{ color: {MUTED}; padding: 12px; background: {PANEL_2}; border: 1px dashed {BORDER}; border-radius: 8px; }} QPushButton#noticeClose {{ background: transparent; border: none; color: {p.muted}; font-size: 14px; padding: 2px; }}
QTableWidget#disabledTable {{ background: #202229; }} QPushButton#noticeClose:hover {{ color: {p.text}; }}
QTableWidget#disabledTable::item:selected {{ background: {ACCENT}; color: #1a1205; }} QLabel#section {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#sectionDisabled {{ color: {p.muted}; font-weight: 600; font-size: 12px; padding: 2px 2px; }}
QLabel#placeholder {{ color: {p.muted}; padding: 12px; background: {p.panel_2}; border: 1px dashed {p.border}; border-radius: 8px; }}
QTableWidget#disabledTable {{ background: {p.disabled_bg}; }}
QTableWidget#disabledTable::item:selected {{ background: {p.accent}; color: {p.on_accent}; }}
QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace; QPlainTextEdit#diag {{ font-family: "Menlo", "Cascadia Code", "Consolas", "DejaVu Sans Mono", monospace;
font-size: 12px; background: #16181d; border: 1px solid {BORDER}; border-radius: 8px; }} font-size: 12px; background: {p.mono_bg}; border: 1px solid {p.border}; border-radius: 8px; }}
QFrame#diagCard {{ background: transparent; border: none; }} QFrame#diagCard {{ background: transparent; border: none; }}
QSplitter::handle {{ background: transparent; }} QSplitter::handle {{ background: transparent; }}
QSplitter::handle:hover {{ background: {BORDER}; border-radius: 4px; }} QSplitter::handle:hover {{ background: {p.border}; border-radius: 4px; }}
QSplitter::handle:pressed {{ background: {ACCENT}; border-radius: 4px; }} QSplitter::handle:pressed {{ background: {p.accent}; border-radius: 4px; }}
""" """
def apply_palette(p: core.Palette) -> str:
"""Rebind the module-level colour names to `p` and return its stylesheet."""
global PALETTE, ACCENT, ACCENT_DIM, BG, PANEL, PANEL_2, TEXT, MUTED, BORDER
global GOOD, BAD, WARN, REMOTE, ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT
global STATUS_COLORS, HEALTH_COLORS
PALETTE = p
ACCENT, ACCENT_DIM = p.accent, p.accent_dim
BG, PANEL, PANEL_2 = p.bg, p.panel, p.panel_2
TEXT, MUTED, BORDER = p.text, p.muted, p.border
GOOD, BAD, WARN, REMOTE = p.good, p.bad, p.warn, p.remote
ON_ACCENT, DISABLED_BG, MONO_BG, SEL_TEXT = (
p.on_accent,
p.disabled_bg,
p.mono_bg,
p.selection_text,
)
STATUS_COLORS = {"ok": GOOD, "missing": BAD, "warn": WARN, "remote": REMOTE, "unknown": WARN}
HEALTH_COLORS = {"ok": GOOD, "failed": BAD, "untested": MUTED}
return build_stylesheet(p)
STYLESHEET = apply_palette(core.DARK_PALETTE)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Background reachability tester (keeps the UI responsive during the request) # Background reachability tester (keeps the UI responsive during the request)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -228,7 +268,7 @@ class _SecretMaskDelegate(QStyledItemDelegate):
if self.revealed or not option.text: if self.revealed or not option.text:
return return
key_item = self._table.item(index.row(), 0) key_item = self._table.item(index.row(), 0)
if key_item and core.is_secret_key(key_item.text()): if key_item and core.should_mask_value(key_item.text(), option.text):
option.text = core.MASK option.text = core.MASK
@@ -1493,6 +1533,54 @@ class AboutDialog(QDialog):
QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL)) QDesktopServices.openUrl(QUrl(self._release_url or core.RELEASES_URL))
class NoticeBanner(QFrame):
"""A persistent, dismissible notice with an optional action button.
The status bar is the wrong home for anything the user needs to act on --
21 call sites rewrite it, so a message posted there is gone by the next
click. That wiped the MSIX warning (#35) and then the update notice (#78).
This is the shared mechanism so it doesn't happen a third time.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("noticeBanner")
row = QHBoxLayout(self)
row.setContentsMargins(10, 8, 8, 8)
row.setSpacing(8)
self._label = QLabel("")
self._label.setObjectName("noticeText")
self._label.setWordWrap(True)
row.addWidget(self._label, 1)
self._action_btn = QPushButton("")
self._action_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._action_btn.hide()
row.addWidget(self._action_btn)
self._close_btn = QPushButton("\u2715")
self._close_btn.setObjectName("noticeClose")
self._close_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self._close_btn.setFixedWidth(26)
self._close_btn.setToolTip("Dismiss")
self._close_btn.clicked.connect(self.hide)
row.addWidget(self._close_btn)
self.hide()
def show_notice(self, text: str, action_label: str = "", on_action=None):
self._label.setText(text)
self._label.setToolTip(text)
# Reconnect cleanly: a banner reused for a second notice would
# otherwise fire the previous notice's action too.
with contextlib.suppress(RuntimeError, TypeError):
self._action_btn.clicked.disconnect()
if action_label and on_action is not None:
self._action_btn.setText(action_label)
self._action_btn.clicked.connect(lambda _=False: on_action())
self._action_btn.show()
else:
self._action_btn.hide()
self.show()
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS # Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
# waiting for the old instance to exit, so it must run off the UI thread. # waiting for the old instance to exit, so it must run off the UI thread.
@@ -1550,6 +1638,11 @@ class MainWindow(QMainWindow):
self.warn_banner.hide() self.warn_banner.hide()
root.addWidget(self.warn_banner) root.addWidget(self.warn_banner)
# Update availability gets its own persistent banner rather than a
# status-line write, which the next UI action overwrites (#78).
self.update_banner = NoticeBanner(self)
root.addWidget(self.update_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)
@@ -1585,11 +1678,96 @@ class MainWindow(QMainWindow):
# --- menu bar ---------------------------------------------------------- # # --- menu bar ---------------------------------------------------------- #
def _build_menu_bar(self): def _build_menu_bar(self):
view_menu = self.menuBar().addMenu("&View")
theme_menu = view_menu.addMenu("Theme")
self._theme_group = QActionGroup(self)
self._theme_group.setExclusive(True)
current = stored_theme_setting()
for setting, label in (
(core.THEME_SYSTEM, "Match system"),
(core.THEME_LIGHT, "Light"),
(core.THEME_DARK, "Dark"),
):
act = QAction(label, self, checkable=True)
act.setChecked(setting == current)
act.triggered.connect(lambda _checked=False, s=setting: self._set_theme(s))
self._theme_group.addAction(act)
theme_menu.addAction(act)
help_menu = self.menuBar().addMenu("&Help") help_menu = self.menuBar().addMenu("&Help")
# "Check for updates" used to exist only as a button inside the About
# dialog, which is not somewhere anyone looks for it (#79).
update_action = QAction("Check for updates…", self)
# Explicit role: macOS relocates actions it recognises by text, and
# some Qt versions treat "update" as application-menu material. Pin it
# so the item stays where the menu says it is on every platform.
update_action.setMenuRole(QAction.MenuRole.ApplicationSpecificRole)
update_action.triggered.connect(self.check_for_updates)
help_menu.addAction(update_action)
help_menu.addSeparator()
about_action = QAction("About Better Claude Config…", self) about_action = QAction("About Better Claude Config…", self)
# Qt auto-assigns AboutRole to actions whose text starts with "About",
# which moves this into the application menu on macOS. That is the
# right home there -- state it explicitly rather than inheriting it by
# accident, since the behaviour is invisible from this call site.
about_action.setMenuRole(QAction.MenuRole.AboutRole)
about_action.triggered.connect(self._show_about) about_action.triggered.connect(self._show_about)
help_menu.addAction(about_action) help_menu.addAction(about_action)
def _show_update_notice(self, notice: dict):
"""Surface an available update where it survives the next click."""
url = notice["url"]
self.update_banner.show_notice(
notice["text"],
action_label="Open releases page",
on_action=lambda: QDesktopServices.openUrl(QUrl(url)),
)
def check_for_updates(self):
"""Menu-driven check. Unlike the startup check this is never throttled
and always reports back -- the user asked, so silence would read as a
broken button."""
self.status.setText("Checking for updates…")
self._menu_update_worker = UpdateCheckWorker()
self._menu_update_worker.done.connect(self._on_menu_update_checked)
self._menu_update_worker.start()
def _on_menu_update_checked(self, release: dict | None):
self._menu_update_worker = None
if release is None:
self.status.setText("Couldn't check for updates (offline?).")
return
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
notice = core.update_notice(core.__version__, release)
if notice:
self._show_update_notice(notice)
self.status.setText(f"Update available: {notice['version']}")
else:
self.update_banner.hide()
self.status.setText(f"You're up to date ({core.__version__}).")
def _set_theme(self, setting: str):
"""Persist the theme choice and repaint the running window."""
QSettings("BCC", "BetterClaudeConfig").setValue("ui/theme", setting)
app = QApplication.instance()
if app is None: # pragma: no cover - only in a headless test harness
return
app.setStyleSheet(theme_stylesheet_for(app, setting))
# The global stylesheet covers most of the UI, but the inline
# setStyleSheet calls (status dots, warning labels, update banner) only
# pick up the new palette when their widget next renders -- so re-render
# them now rather than leaving dark-on-light text behind.
self._repaint_themed_widgets()
def _repaint_themed_widgets(self):
"""Re-run the inline-styled bits after a palette change."""
self.status.setStyleSheet(f"color: {MUTED};")
idx = self._current_index()
self._refresh_tables(select_index=idx if idx >= 0 else -1)
self._update_status(saved=False)
def _show_about(self): def _show_about(self):
AboutDialog(self).exec() AboutDialog(self).exec()
@@ -1610,10 +1788,9 @@ class MainWindow(QMainWindow):
if release is None: if release is None:
return # offline/failed check: don't advance lastCheck, allow retry return # offline/failed check: don't advance lastCheck, allow retry
QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time()) QSettings("BCC", "BetterClaudeConfig").setValue("update/lastCheck", time.time())
if core.is_newer_version(core.__version__, release["version"]): notice = core.update_notice(core.__version__, release)
self.status.setText( if notice:
f"Update available: {release['version']} · Help ▸ About to view it." self._show_update_notice(notice)
)
# --- layout persistence ---------------------------------------------- # # --- layout persistence ---------------------------------------------- #
def _restore_layout(self): def _restore_layout(self):
@@ -1866,6 +2043,11 @@ class MainWindow(QMainWindow):
for p in self.profiles: for p in self.profiles:
tag = "" if p.config_exists else " (no config yet)" tag = "" if p.config_exists else " (no config yet)"
self.profile_combo.addItem(f"{p.label}{tag}") self.profile_combo.addItem(f"{p.label}{tag}")
# Full path in the tooltip so a profile is always verifiable even
# when two labels look alike (e.g. two repos both named "app").
self.profile_combo.setItemData(
self.profile_combo.count() - 1, str(p.path), Qt.ItemDataRole.ToolTipRole
)
self.profile_combo.blockSignals(False) self.profile_combo.blockSignals(False)
if self.profiles: if self.profiles:
self.profile_combo.setCurrentIndex(0) self.profile_combo.setCurrentIndex(0)
@@ -1939,9 +2121,21 @@ class MainWindow(QMainWindow):
return return
self.full_config = cfg self.full_config = cfg
repaired = True repaired = True
# extract_servers tolerates malformed entries rather than raising (#72),
# but keep it inside the guard: a load failure must leave the previously
# loaded profile intact instead of half-swapping the window's state.
try:
servers = core.extract_servers(self.full_config, profile.client)
except Exception as exc: # pragma: no cover - defence in depth
QMessageBox.critical(
self,
"Could not read config",
f"{profile.path}\n\nThe server list couldn't be read: {exc}",
)
return
self._loaded_stat = core.config_fingerprint(profile.path) self._loaded_stat = core.config_fingerprint(profile.path)
self.current_profile = profile self.current_profile = profile
self.servers = core.extract_servers(self.full_config) self.servers = servers
self.dirty = False self.dirty = False
self.restart_btn.hide() self.restart_btn.hide()
self._undo_stack.clear() self._undo_stack.clear()
@@ -2264,7 +2458,7 @@ class MainWindow(QMainWindow):
entry = self.servers[idx] entry = self.servers[idx]
old_name = entry.name old_name = entry.name
entry.name = self.editor.current_name() entry.name = self.editor.current_name()
entry.data = self.editor.dump_data() entry.set_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
@@ -2358,7 +2552,7 @@ class MainWindow(QMainWindow):
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
) )
if ans == QMessageBox.StandardButton.Yes: if ans == QMessageBox.StandardButton.Yes:
self.servers[existing[name]].data = data self.servers[existing[name]].set_data(data)
return False, True return False, True
name = core.resolve_name_collision(name, {s.name for s in self.servers}) name = core.resolve_name_collision(name, {s.name for s in self.servers})
self.servers.append(core.ServerEntry(name, data, True)) self.servers.append(core.ServerEntry(name, data, True))
@@ -2401,7 +2595,7 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}") QMessageBox.critical(self, "Copy failed", f"Couldn't read {dest.label}:\n{e}")
return return
existing = core.extract_servers(dest_cfg) existing = core.extract_servers(dest_cfg, dest.client)
names = {s.name for s in existing} names = {s.name for s in existing}
if src.name in names: if src.name in names:
ans = QMessageBox.question( ans = QMessageBox.question(
@@ -2413,7 +2607,7 @@ class MainWindow(QMainWindow):
return return
existing = [s for s in existing if s.name != src.name] existing = [s for s in existing if s.name != src.name]
existing.append(core.ServerEntry(src.name, dict(src.data), True)) existing.append(core.ServerEntry(src.name, dict(src.data), True))
core.apply_servers(dest_cfg, existing) core.apply_servers(dest_cfg, existing, dest.client)
try: try:
backup = core.write_config(dest.path, dest_cfg) backup = core.write_config(dest.path, dest_cfg)
except Exception as e: except Exception as e:
@@ -2433,6 +2627,12 @@ class MainWindow(QMainWindow):
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
return False return False
lint_warnings = core.lint_servers(self.servers) lint_warnings = core.lint_servers(self.servers)
# ${VAR} references are only meaningful if the target client expands
# them -- Claude Desktop doesn't, so the same config is fine in one
# profile and broken in another (#76). Report against the loaded one.
for entry in self.servers:
for warning in core.env_ref_warnings(entry.data, self.current_profile):
lint_warnings.append(f"'{entry.name}': {warning}")
if lint_warnings: if lint_warnings:
self.validation_lbl.setText(f"⚠ {lint_warnings[0]}") self.validation_lbl.setText(f"⚠ {lint_warnings[0]}")
self.validation_lbl.setStyleSheet(f"color: {WARN};") self.validation_lbl.setStyleSheet(f"color: {WARN};")
@@ -2467,7 +2667,7 @@ class MainWindow(QMainWindow):
and disk_stat != self._loaded_stat and disk_stat != self._loaded_stat
): ):
changed_keys, server_diff = core.external_change_summary( changed_keys, server_diff = core.external_change_summary(
self.full_config, self.current_profile.path self.full_config, self.current_profile.path, self.current_profile.client
) )
dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff) dlg = StaleDialog(self, str(self.current_profile.path), changed_keys, server_diff)
if not dlg.exec(): if not dlg.exec():
@@ -2478,7 +2678,12 @@ class MainWindow(QMainWindow):
except Exception as e: except Exception as e:
QMessageBox.critical(self, "Reload failed", str(e)) QMessageBox.critical(self, "Reload failed", str(e))
return return
core.apply_servers(fresh, self.servers) # The reload above is the on-disk truth for everything the user
# didn't touch -- but it also wipes BCC-authored keys the user
# changed in this session (named sets), which apply_servers
# doesn't write. Carry them over before saving (#73).
contested = core.carry_owned_keys(self.full_config, fresh)
core.apply_servers(fresh, self.servers, self.current_profile.client)
try: try:
backup = core.write_config(self.current_profile.path, fresh) backup = core.write_config(self.current_profile.path, fresh)
except Exception as e: except Exception as e:
@@ -2490,15 +2695,20 @@ class MainWindow(QMainWindow):
self.dirty = False self.dirty = False
self.save_btn.setEnabled(False) self.save_btn.setEnabled(False)
bnote = f" · backup: {backup.name}" if backup else " · (new file)" bnote = f" · backup: {backup.name}" if backup else " · (new file)"
cnote = (
f" · kept your {', '.join(contested)} (the file on disk had a different copy)"
if contested
else ""
)
self.status.setText( self.status.setText(
f"Merged & saved {self.current_profile.path}{bnote}" f"Merged & saved {self.current_profile.path}{bnote}{cnote}"
f" · Restart {self.current_profile.label} to apply." f" · Restart {self.current_profile.label} to apply."
) )
self._offer_restart_button() self._offer_restart_button()
return return
# else OVERWRITE: fall through to normal write # else OVERWRITE: fall through to normal write
core.apply_servers(self.full_config, self.servers) core.apply_servers(self.full_config, self.servers, self.current_profile.client)
try: try:
backup = core.write_config(self.current_profile.path, self.full_config) backup = core.write_config(self.current_profile.path, self.full_config)
except Exception as e: except Exception as e:
@@ -2649,6 +2859,33 @@ class MainWindow(QMainWindow):
e.accept() e.accept()
def system_is_dark(app: QApplication) -> bool:
"""Whether the desktop is currently using a dark appearance.
Read from the style's own window colour rather than per-platform APIs --
Qt has already resolved the OS appearance by the time it builds the
default palette, so this works the same on all three platforms.
"""
try:
return app.palette().color(QPalette.ColorRole.Window).lightness() < 128
except Exception: # pragma: no cover - defensive; never block startup on theming
return True
def stored_theme_setting() -> str:
"""The user's theme choice, defaulting to following the system."""
value = QSettings("BCC", "BetterClaudeConfig").value("ui/theme", core.THEME_SYSTEM)
return value if value in core.THEME_CHOICES else core.THEME_SYSTEM
def theme_stylesheet_for(app: QApplication, setting: str | None = None) -> str:
"""Resolve setting + OS appearance into a palette, apply it, return the QSS."""
if setting is None:
setting = stored_theme_setting()
theme = core.resolve_theme(setting, system_is_dark(app))
return apply_palette(core.palette_for(theme))
def main(): def main():
if sys.platform == "win32": if sys.platform == "win32":
# Without an explicit AppUserModelID, Windows taskbar groups the app # Without an explicit AppUserModelID, Windows taskbar groups the app
@@ -2667,7 +2904,7 @@ def main():
icon = _app_icon() icon = _app_icon()
if not icon.isNull(): if not icon.isNull():
app.setWindowIcon(icon) app.setWindowIcon(icon)
app.setStyleSheet(STYLESHEET) app.setStyleSheet(theme_stylesheet_for(app))
win = MainWindow() win = MainWindow()
win.show() win.show()
sys.exit(app.exec()) sys.exit(app.exec())
+711 -38
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
import base64 import base64
import contextlib import contextlib
import copy
import difflib import difflib
import functools import functools
import glob import glob
@@ -49,6 +50,12 @@ DISABLED_KEY = "_disabledMcpServers"
# parks the rest under DISABLED_KEY. # parks the rest under DISABLED_KEY.
SETS_KEY = "_bccServerSets" SETS_KEY = "_bccServerSets"
# Top-level keys BCC itself authors. They live in the client's config file, but
# BCC is their owner, so on a stale-file merge the in-memory copy wins over the
# on-disk one (see `carry_owned_keys`). Any future BCC-authored key belongs
# here -- forgetting to add one is exactly how #73 happened.
BCC_OWNED_KEYS = (SETS_KEY,)
BACKUP_DIRNAME = ".bcc_backups" BACKUP_DIRNAME = ".bcc_backups"
MAX_BACKUPS = 15 MAX_BACKUPS = 15
@@ -163,31 +170,380 @@ def fetch_latest_release(timeout: float = 4.0) -> dict | None:
return {"version": tag, "url": payload.get("html_url") or RELEASES_URL} return {"version": tag, "url": payload.get("html_url") or RELEASES_URL}
def update_notice(
current: str, release: dict | None, url_fallback: str = RELEASES_URL
) -> dict | None:
"""Decide whether to tell the user about a release, and what to say.
Returns {"version", "text", "url"} when `release` is newer than `current`,
or None when it isn't, when the check failed, or when the payload is
malformed. Kept here rather than in the GUI so the wording and the
should-we-notify decision are testable -- bcc.py can't be imported by the
test suite, which has no PySide6.
The text deliberately names no menu path. The old status-line notice read
"Help > About to view it", which is wrong on macOS: Qt relocates the About
action into the application menu (#79). A notice that carries its own
action can't drift out of sync with the platform.
"""
if not isinstance(release, dict):
return None
version = release.get("version")
if not version or not isinstance(version, str):
return None
if not is_newer_version(current, version):
return None
# Tags carry a "v" prefix and __version__ doesn't; render both the same way
# so the notice doesn't read "Version v1.3.0 ... you're running 1.2.0".
shown = version.lstrip("vV")
return {
"version": version,
"text": f"Version {shown} is available. You're running {current.lstrip('vV')}.",
"url": release.get("url") or url_fallback,
}
# --------------------------------------------------------------------------- #
# Client adapters (issue #5 — cross-client support)
# --------------------------------------------------------------------------- #
# BCC used to hard-code Claude's layout everywhere: the servers always lived
# under the literal key "mcpServers", the config was always one of a fixed set
# of Claude file locations, and "which client is this?" was answered by
# comparing a filename. Other editors differ along exactly three axes:
#
# * the top-level key the servers map lives under (VS Code uses "servers")
# * where the config files are found (each client, its own paths)
# * the per-server value shape (VS Code adds `type`/inputs)
#
# A ClientSpec captures all three in one place, plus the small capability flags
# that used to be answered by filename sniffing (does the client expand ${VAR}
# itself? can we offer a Restart action?). Everything downstream — tables,
# editor, backups, stale-file check, secret masking — works against BCC's
# internal ServerEntry model and never needs to know which client it came from.
#
# Phase 1 (this change) introduces the abstraction and routes the two existing
# clients (Claude Desktop, Claude Code) through it with NO behaviour change:
# both use "mcpServers" + the _disabledMcpServers parking key, so their entry
# translation is the identity. Cursor/Windsurf (same shape, new paths) and
# VS Code (different key + per-server shape) become small, isolated additions
# on top of this seam — see issue #5's phased plan.
@dataclass(frozen=True)
class ClientSpec:
"""Everything client-specific about one MCP host.
Frozen so the module-level specs are effectively singletons and safe to
share on every Profile. The `entry_*` translators are the extension point
for a client whose stored server value isn't already BCC's internal shape;
for every mcpServers-shaped client they're the identity. A future VS Code
spec overrides them (subclass, or a spec built with translating callables)
to map its `type`/inputs form to and from the internal model.
"""
key: str
label: str
servers_key: str = "mcpServers"
# The parking key for disabled servers. None means the client has no place
# to keep a disabled definition (we'd just drop it); every client so far
# supports one.
disabled_key: str | None = DISABLED_KEY
# Basename that identifies this client's config on disk. Used only to keep
# `profile_targets_claude_desktop` answering exactly as it did before.
config_filename: str | None = None
# The client resolves ${VAR} references itself (Claude Code does; Claude
# Desktop does not — see client_expands_env_refs / issue #76).
expands_env_refs: bool = False
# A "Restart <client>" action makes sense (Claude Desktop only so far).
supports_restart: bool = False
def entry_to_internal(self, value):
"""Client's stored value for one server -> BCC internal server data.
Identity for mcpServers-shaped clients. Non-dict values are passed
through untouched so `_server_entry` can preserve a malformed entry
verbatim (#72) rather than this layer having to know about that case.
"""
return value
def entry_from_internal(self, data):
"""BCC internal server data -> the client's stored value for one server."""
return data
def enabled_block(self, cfg: dict) -> dict:
"""The map of enabled servers from a raw config dict (never None)."""
return cfg.get(self.servers_key) or {}
def disabled_block(self, cfg: dict) -> dict:
"""The map of parked/disabled servers from a raw config dict."""
if not self.disabled_key:
return {}
return cfg.get(self.disabled_key) or {}
def section_keys(self) -> tuple[str, ...]:
"""The top-level config keys this client's servers live under."""
if self.disabled_key:
return (self.servers_key, self.disabled_key)
return (self.servers_key,)
CLAUDE_DESKTOP = ClientSpec(
key="claude_desktop",
label="Claude Desktop",
servers_key="mcpServers",
disabled_key=DISABLED_KEY,
config_filename=CONFIG_FILENAME,
expands_env_refs=False,
supports_restart=True,
)
CLAUDE_CODE = ClientSpec(
key="claude_code",
label="Claude Code",
servers_key="mcpServers",
disabled_key=DISABLED_KEY,
config_filename=None,
expands_env_refs=True,
supports_restart=False,
)
# Registry of known clients, and the default used when a caller doesn't supply
# a spec. The default deliberately matches the pre-refactor constants
# (mcpServers + _disabledMcpServers) so every existing call site and test that
# omits a spec behaves exactly as before.
CLIENT_SPECS: tuple[ClientSpec, ...] = (CLAUDE_DESKTOP, CLAUDE_CODE)
DEFAULT_CLIENT = CLAUDE_DESKTOP
def client_by_key(key: str) -> ClientSpec | None:
"""Look up a registered ClientSpec by its stable `key`, or None."""
for spec in CLIENT_SPECS:
if spec.key == key:
return spec
return None
def resolve_client(path: str | os.PathLike) -> ClientSpec:
"""Pick the ClientSpec for a config path.
Reproduces the pre-refactor rule exactly: a file named
`claude_desktop_config.json` is Claude Desktop; everything else BCC edits
(`~/.claude.json`, a project `.mcp.json`, the legacy settings.json) is
Claude Code. That one rule is what `profile_targets_claude_desktop` and
`client_expands_env_refs` used to compute inline; now it lives here.
"""
return CLAUDE_DESKTOP if Path(path).name == CONFIG_FILENAME else CLAUDE_CODE
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Data model # Data model
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@dataclass @dataclass
class Profile: class Profile:
"""A discovered (or manually added) Claude install location.""" """A discovered (or manually added) MCP client config location."""
label: str label: str
path: Path path: Path
config_exists: bool config_exists: bool
# Which client this config belongs to. Left None by most callers and
# resolved from the path (the historical rule), so existing
# Profile(label=, path=, config_exists=) construction keeps working and
# gets the right adapter for free.
client: ClientSpec | None = None
def __post_init__(self): def __post_init__(self):
self.path = Path(self.path) self.path = Path(self.path)
if self.client is None:
self.client = resolve_client(self.path)
class _NoRaw:
"""Sentinel for ServerEntry.raw.
`None` can't do this job: `{"mcpServers": {"foo": null}}` is legal JSON and
a real malformed-entry case, so None has to mean "the config said null",
not "there was nothing here".
"""
__slots__ = ()
def __repr__(self) -> str: # keeps ServerEntry reprs readable in test output
return "<no raw>"
NO_RAW = _NoRaw()
@dataclass @dataclass
class ServerEntry: class ServerEntry:
"""One server definition.
`data` is always a dict so every consumer can treat it as one. When the
config held something that wasn't a JSON object for this server (a string,
a number, a list -- all legal JSON, all wrong here), `data` is empty and
the original value is preserved verbatim in `raw` so Save round-trips it
instead of silently deleting the user's line. `lint_servers` surfaces it.
`raw` defaults to the NO_RAW sentinel rather than None, because a config
value of literal `null` is itself a malformed entry worth preserving.
Assigning `data` means the user replaced the definition through the editor,
which retires `raw` -- use `set_data` so that can't be forgotten.
"""
name: str name: str
data: dict data: dict
enabled: bool = True enabled: bool = True
raw: object = NO_RAW
@property @property
def kind(self) -> str: def kind(self) -> str:
return "remote" if "url" in self.data and "command" not in self.data else "stdio" return "remote" if "url" in self.data and "command" not in self.data else "stdio"
@property
def malformed(self) -> bool:
"""True when the config value for this server wasn't a JSON object."""
return self.raw is not NO_RAW
def set_data(self, data: dict) -> None:
"""Replace the definition from the editor, clearing any malformed original."""
self.data = data
self.raw = NO_RAW
def config_value(self):
"""What to write back to the config: the edited dict, or the untouched
malformed original when the user never edited it."""
return self.data if self.raw is NO_RAW else self.raw
# --------------------------------------------------------------------------- #
# Theming (issue #75)
# --------------------------------------------------------------------------- #
THEME_SYSTEM = "system"
THEME_LIGHT = "light"
THEME_DARK = "dark"
THEME_CHOICES = (THEME_SYSTEM, THEME_LIGHT, THEME_DARK)
@dataclass(frozen=True)
class Palette:
"""Every colour the UI draws with.
Deliberately exhaustive: the stylesheet used to inline a handful of
near-black literals (`#1a1205` for text on the accent, `#202229` for the
disabled table, `#16181d` for the diagnostics pane), which is fine while
there's one theme and invisible breakage the moment there are two. Each
gets a slot here so a light palette can't silently inherit a dark value.
"""
name: str
accent: str
accent_dim: str
bg: str
panel: str
panel_2: str
text: str
muted: str
border: str
good: str
bad: str
warn: str
remote: str
on_accent: str # text drawn on top of an accent fill
disabled_bg: str # the parked-servers table
mono_bg: str # diagnostics / log panes
selection_text: str
# The shipping theme through v1.3.0. These values are carried over verbatim --
# adding a light theme must not restyle the dark one.
DARK_PALETTE = Palette(
name="dark",
accent="#f97316",
accent_dim="#c2570b",
bg="#1b1d23",
panel="#23262e",
panel_2="#2b2f39",
text="#e7e9ee",
muted="#9aa0ad",
border="#3a3f4b",
good="#4ade80",
bad="#f87171",
warn="#fbbf24",
remote="#60a5fa",
on_accent="#1a1205",
disabled_bg="#202229",
mono_bg="#16181d",
selection_text="#ffffff",
)
# The semantic colours are NOT the dark ones lightened. #4ade80 / #fbbf24 sit
# around 1.7:1 against white -- illegible. These are darkened to clear 4.5:1,
# which `test_light_palette_meets_contrast` enforces so nobody "tidies" them
# back toward the dark hues later.
LIGHT_PALETTE = Palette(
name="light",
accent="#c2410c",
accent_dim="#9a3412",
bg="#f6f7f9",
panel="#ffffff",
panel_2="#eef0f4",
text="#1b1d23",
muted="#5c6270",
border="#d3d7de",
good="#15803d",
bad="#b91c1c",
warn="#a16207",
remote="#1d4ed8",
on_accent="#ffffff",
disabled_bg="#e9ebef",
mono_bg="#f0f2f5",
selection_text="#ffffff",
)
PALETTES = {DARK_PALETTE.name: DARK_PALETTE, LIGHT_PALETTE.name: LIGHT_PALETTE}
def resolve_theme(setting: str, system_is_dark: bool) -> str:
"""Map a stored theme setting + the OS appearance onto a concrete palette name.
Anything unrecognised (a hand-edited QSettings value, a setting written by
a future version) falls back to following the system rather than to a
fixed theme -- the user's desktop is the better guess.
"""
if setting == THEME_DARK:
return THEME_DARK
if setting == THEME_LIGHT:
return THEME_LIGHT
return THEME_DARK if system_is_dark else THEME_LIGHT
def palette_for(theme: str) -> Palette:
"""Concrete palette by name; unknown names fall back to dark (the historical look)."""
return PALETTES.get(theme, DARK_PALETTE)
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
v = value.lstrip("#")
if len(v) == 3:
v = "".join(ch * 2 for ch in v)
return int(v[0:2], 16), int(v[2:4], 16), int(v[4:6], 16)
def relative_luminance(color: str) -> float:
"""WCAG relative luminance for a #rrggbb colour."""
def chan(c: int) -> float:
srgb = c / 255.0
return srgb / 12.92 if srgb <= 0.04045 else ((srgb + 0.055) / 1.055) ** 2.4
r, g, b = (chan(c) for c in _hex_to_rgb(color))
return 0.2126 * r + 0.7152 * g + 0.0722 * b
def contrast_ratio(fg: str, bg: str) -> float:
"""WCAG contrast ratio between two #rrggbb colours (1.0 to 21.0)."""
a, b = relative_luminance(fg), relative_luminance(bg)
lighter, darker = max(a, b), min(a, b)
return (lighter + 0.05) / (darker + 0.05)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Discovery # Discovery
@@ -292,16 +648,67 @@ def msix_warning_text(
) )
def _dir_name_parts(directory: Path) -> list[str]:
"""The path components of `directory` with the filesystem anchor dropped.
`/home/me/work/app` -> ['home', 'me', 'work', 'app']; `C:\\src\\app` ->
['src', 'app']. Falls back to the raw name for an anchor-only path so a
label is never empty.
"""
parts = [p for p in directory.parts if p != directory.anchor]
return parts or [directory.name or str(directory)]
def disambiguate_project_labels(dirs: list[Path]) -> dict[Path, str]:
"""Map each project directory to a unique `Project: <suffix>` label.
The suffix is the directory's basename, widened toward the root one
component at a time ONLY for directories whose current suffix collides with
another in the set -- so the common case stays `Project: app`, and two repos
both named `app` become `Project: work/app` and `Project: personal/app`
(widening further if even that collides). Deterministic; expects `dirs` to
already be de-duplicated by path (identical paths can't be told apart).
"""
depth = {d: 1 for d in dirs}
max_depth = {d: len(_dir_name_parts(d)) for d in dirs}
def suffix(d: Path) -> str:
return "/".join(_dir_name_parts(d)[-depth[d] :])
# Widen colliding groups until every label is unique or no group can widen.
while True:
groups: dict[str, list[Path]] = {}
for d in dirs:
groups.setdefault(suffix(d), []).append(d)
widened = False
for members in groups.values():
if len(members) > 1:
for d in members:
if depth[d] < max_depth[d]:
depth[d] += 1
widened = True
if not widened:
break
return {d: f"Project: {suffix(d)}" for d in dirs}
def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]: def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]:
""" """
Find project-scope `.mcp.json` configs known to Claude Code. Find project-scope `.mcp.json` configs known to Claude Code.
`~/.claude.json` keeps a `projects` map keyed by absolute project `~/.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). 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 Any project whose directory has a `.mcp.json` file that parses to a JSON
standalone file with a top-level `mcpServers` object, same shape BCC object -- the same shape BCC already edits -- is surfaced here as its own
already edits -- is surfaced here as its own profile so it can be opened profile so it can be opened via 'Add config...' without hunting for the
via 'Add config...' without hunting for the path by hand. path by hand. A `.mcp.json` that is missing, unreadable, or not a JSON
object (an array, a string, garbage) is skipped rather than surfaced as a
profile that only fails when opened.
Labels are disambiguated so two repos with the same basename don't both
read as `Project: app` and let the user edit the wrong one (see
`disambiguate_project_labels`).
Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a 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 `projects` value that isn't a dict, just yields an empty list rather than
@@ -314,14 +721,22 @@ def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profil
projects = cfg.get("projects") projects = cfg.get("projects")
if not isinstance(projects, dict): if not isinstance(projects, dict):
return [] return []
out: list[Profile] = []
valid_dirs: list[Path] = []
for key in sorted(k for k in projects if isinstance(k, str)): for key in sorted(k for k in projects if isinstance(k, str)):
mcp_path = Path(key) / ".mcp.json" mcp_path = Path(key) / ".mcp.json"
if mcp_path.is_file(): if not mcp_path.is_file():
out.append( continue
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True) try:
) data = load_config(mcp_path) # strict; raises on bad JSON / non-object
return out except Exception:
continue
if not isinstance(data, dict):
continue
valid_dirs.append(Path(key))
labels = disambiguate_project_labels(valid_dirs)
return [Profile(label=labels[d], path=d / ".mcp.json", config_exists=True) for d in valid_dirs]
def discover_profiles() -> list[Profile]: def discover_profiles() -> list[Profile]:
@@ -404,8 +819,12 @@ def profile_targets_claude_desktop(profile: Profile) -> bool:
or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions or the legacy ~/.claude/settings.json). Used to gate Desktop-only actions
like "Restart Claude Desktop" so they never show up for a Claude Code like "Restart Claude Desktop" so they never show up for a Claude Code
profile -- restarting the CLI makes no sense. profile -- restarting the CLI makes no sense.
Now a thin read of the profile's resolved ClientSpec -- the identity of the
client lives on the spec instead of in scattered filename checks -- but the
answer is unchanged: true iff the config is a claude_desktop_config.json.
""" """
return Path(profile.path).name == CONFIG_FILENAME return profile.client.config_filename == CONFIG_FILENAME
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
@@ -452,13 +871,36 @@ def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]:
return obj, notes, pretty return obj, notes, pretty
def extract_servers(cfg: dict) -> list[ServerEntry]: def _server_entry(name: str, data, enabled: bool) -> ServerEntry:
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" """Build a ServerEntry, tolerating a value that isn't a JSON object.
A hand-edited config can legally hold `{"mcpServers": {"foo": "oops"}}` --
valid JSON, wrong shape. Calling dict() on that raises, which used to take
the whole load down before the linter ever got a look at it (#72). Keep the
original instead and let the linter report it.
"""
if isinstance(data, dict):
return ServerEntry(name=name, data=dict(data), enabled=enabled)
return ServerEntry(name=name, data={}, enabled=enabled, raw=data)
def extract_servers(cfg: dict, spec: ClientSpec | None = None) -> list[ServerEntry]:
"""Pull enabled and disabled servers for `spec` (default: Claude's layout).
Reads the client's servers key + parking key and runs each stored value
through the client's `entry_to_internal` translator. With no `spec` this is
byte-for-byte the old behaviour (`mcpServers` + `_disabledMcpServers`,
identity translation).
Never raises on a structurally-odd config -- malformed entries come back as
empty-data entries carrying their original value (see `_server_entry`).
"""
spec = spec or DEFAULT_CLIENT
out: list[ServerEntry] = [] out: list[ServerEntry] = []
for name, data in (cfg.get("mcpServers") or {}).items(): for name, value in spec.enabled_block(cfg).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=True)) out.append(_server_entry(name, spec.entry_to_internal(value), True))
for name, data in (cfg.get(DISABLED_KEY) or {}).items(): for name, value in spec.disabled_block(cfg).items():
out.append(ServerEntry(name=name, data=dict(data), enabled=False)) out.append(_server_entry(name, spec.entry_to_internal(value), False))
return out return out
@@ -545,25 +987,63 @@ def resolve_name_collision(name: str, existing: set[str]) -> str:
return candidate return candidate
def apply_servers(cfg: dict, servers: list[ServerEntry]) -> dict: def apply_servers(cfg: dict, servers: list[ServerEntry], spec: ClientSpec | None = None) -> dict:
""" """
Write the server list back into `cfg` in place, preserving every other key Write the server list back into `cfg` in place, preserving every other key
and the position of `mcpServers`. Returns the same dict for convenience. and the position of the client's servers key. Returns the same dict for
""" convenience.
enabled = {s.name: s.data for s in servers if s.enabled}
disabled = {s.name: s.data for s in servers if not s.enabled}
cfg["mcpServers"] = enabled # replaces value if key existed; appends otherwise The cardinal rule generalises cleanly: this still only ever touches the two
keys the client's servers live under (`spec.servers_key` and, if the client
has one, `spec.disabled_key`) and leaves everything else verbatim. With no
`spec` it writes `mcpServers` + `_disabledMcpServers` exactly as before.
"""
spec = spec or DEFAULT_CLIENT
enabled = {s.name: spec.entry_from_internal(s.config_value()) for s in servers if s.enabled}
disabled = {
s.name: spec.entry_from_internal(s.config_value()) for s in servers if not s.enabled
}
cfg[spec.servers_key] = enabled # replaces value if key existed; appends otherwise
if spec.disabled_key:
if disabled: if disabled:
cfg[DISABLED_KEY] = disabled cfg[spec.disabled_key] = disabled
else: else:
cfg.pop(DISABLED_KEY, None) cfg.pop(spec.disabled_key, None)
return cfg return cfg
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# Write (atomic, with rotating backups) # Write (atomic, with rotating backups)
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
def carry_owned_keys(local_cfg: dict, fresh_cfg: dict) -> list[str]:
"""Carry BCC-authored top-level keys from `local_cfg` onto `fresh_cfg`.
Used by the stale-file "Merge & save" path, which reloads the file from
disk and re-applies the user's server edits. That reload used to drop
anything BCC owns but `apply_servers` doesn't write -- named server sets
vanished without a word (#73). BCC owns these keys, so the in-memory copy
wins; mutates `fresh_cfg` in place.
Returns the keys where the on-disk copy differed and was overwritten, so
the caller can tell the user something was actually contested rather than
merely carried across.
Deliberately one-directional: a key absent locally is left alone on disk.
We can't tell "user deleted their last set" from "user never had sets and
another machine just added some", and silently deleting someone else's
data is the worse of the two failures.
"""
conflicts: list[str] = []
for key in BCC_OWNED_KEYS:
if key not in local_cfg:
continue
if key in fresh_cfg and fresh_cfg[key] != local_cfg[key]:
conflicts.append(key)
fresh_cfg[key] = copy.deepcopy(local_cfg[key])
return conflicts
def _make_backup(path: Path) -> Path: def _make_backup(path: Path) -> Path:
bdir = path.parent / BACKUP_DIRNAME bdir = path.parent / BACKUP_DIRNAME
bdir.mkdir(exist_ok=True) bdir.mkdir(exist_ok=True)
@@ -629,13 +1109,28 @@ def backup_label(backup_path: Path | str) -> str:
return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}" return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}:{ts[13:]}"
def should_mask_value(key: str, value) -> bool:
"""Whether an env/header value should be masked for display.
A ${VAR} reference is NOT a secret -- it's a pointer to one, and it's the
thing we want users to adopt. Masking it to dots would make a reference
indistinguishable from a stored credential, hiding exactly the distinction
that makes the feature worth using (#76).
"""
if not is_secret_key(key):
return False
return not is_env_ref(value) if isinstance(value, str) else True
def _redact_server_data(data: dict) -> dict: def _redact_server_data(data: dict) -> dict:
"""Return a copy of a server definition with secrets masked for display.""" """Return a copy of a server definition with secrets masked for display."""
out = dict(data) out = dict(data)
if "args" in out: if "args" in out:
out["args"] = redact_args(list(out["args"] or [])) out["args"] = redact_args(list(out["args"] or []))
if "env" in out: if "env" in out:
out["env"] = {k: (MASK if is_secret_key(k) else v) for k, v in (out["env"] or {}).items()} out["env"] = {
k: (MASK if should_mask_value(k, v) else v) for k, v in (out["env"] or {}).items()
}
return out return out
@@ -646,11 +1141,12 @@ def _redact_servers_block(block: dict | None) -> dict:
return {name: _redact_server_data(data) for name, data in block.items()} return {name: _redact_server_data(data) for name, data in block.items()}
def _server_sections(cfg: dict) -> dict: def _server_sections(cfg: dict, spec: ClientSpec | None = None) -> dict:
"""Return the masked server sections of a config dict, safe for diff display.""" """Return the masked server sections of a config dict, safe for diff display."""
out: dict = {"mcpServers": _redact_servers_block(cfg.get("mcpServers"))} spec = spec or DEFAULT_CLIENT
if DISABLED_KEY in cfg: out: dict = {spec.servers_key: _redact_servers_block(cfg.get(spec.servers_key))}
out[DISABLED_KEY] = _redact_servers_block(cfg.get(DISABLED_KEY)) if spec.disabled_key and spec.disabled_key in cfg:
out[spec.disabled_key] = _redact_servers_block(cfg.get(spec.disabled_key))
return out return out
@@ -760,7 +1256,9 @@ def config_fingerprint(path: Path | str) -> ConfigStat | None:
return ConfigStat(st.st_mtime, st.st_size) return ConfigStat(st.st_mtime, st.st_size)
def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[str], str]: def external_change_summary(
original_cfg: dict, path: Path | str, spec: ClientSpec | None = None
) -> tuple[list[str], str]:
""" """
Compare original_cfg (what BCC loaded) with the current on-disk state. Compare original_cfg (what BCC loaded) with the current on-disk state.
@@ -769,6 +1267,7 @@ def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[
server_diff — masked unified diff of server sections (empty if unchanged server_diff — masked unified diff of server sections (empty if unchanged
or the file cannot be read). or the file cannot be read).
""" """
spec = spec or DEFAULT_CLIENT
try: try:
disk_cfg = load_config(Path(path)) disk_cfg = load_config(Path(path))
except Exception: except Exception:
@@ -778,12 +1277,12 @@ def external_change_summary(original_cfg: dict, path: Path | str) -> tuple[list[
changed_keys = sorted(k for k in all_keys if original_cfg.get(k) != disk_cfg.get(k)) changed_keys = sorted(k for k in all_keys if original_cfg.get(k) != disk_cfg.get(k))
server_diff = "" server_diff = ""
if any(k in {"mcpServers", DISABLED_KEY} for k in changed_keys): if any(k in set(spec.section_keys()) for k in changed_keys):
before_lines = ( before_lines = (
json.dumps(_server_sections(original_cfg), indent=2, ensure_ascii=False) + "\n" json.dumps(_server_sections(original_cfg, spec), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True) ).splitlines(keepends=True)
after_lines = ( after_lines = (
json.dumps(_server_sections(disk_cfg), indent=2, ensure_ascii=False) + "\n" json.dumps(_server_sections(disk_cfg, spec), indent=2, ensure_ascii=False) + "\n"
).splitlines(keepends=True) ).splitlines(keepends=True)
server_diff = "".join( server_diff = "".join(
difflib.unified_diff(before_lines, after_lines, fromfile="loaded", tofile="on disk now") difflib.unified_diff(before_lines, after_lines, fromfile="loaded", tofile="on disk now")
@@ -1186,6 +1685,158 @@ MASK = "••••••••"
_EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@") _EMBEDDED_CRED_RE = re.compile(r"://[^:@/\s]+:[^:@/\s]+@")
# --------------------------------------------------------------------------- #
# Environment-variable references (issue #76)
#
# Claude Code expands ${VAR} and ${VAR:-default} itself, in command, args, env,
# url and headers. So BCC does NOT expand these on write -- resolving them into
# the file would put the secret back on disk, which is the whole thing the user
# is avoiding, and would defeat a feature the client already implements. BCC
# authors, validates and warns.
#
# Claude Desktop has no documented support, so the same text there is passed to
# the server literally. That makes this a per-client capability, not a global
# one -- see client_expands_env_refs().
# --------------------------------------------------------------------------- #
# ${NAME} or ${NAME:-default}. Names follow the shell convention (letter or
# underscore first) so a bare "${}" or "${1}" isn't mistaken for a reference.
_ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
# The five fields Claude Code documents as expansion sites.
ENV_REF_FIELDS = ("command", "args", "env", "url", "headers")
class EnvRef(NamedTuple):
"""One ${VAR} / ${VAR:-default} occurrence found in a server definition."""
name: str
default: str | None
field: str # which of ENV_REF_FIELDS it was found in
@property
def has_default(self) -> bool:
return self.default is not None
def find_env_refs(text: str, field: str = "") -> list[EnvRef]:
"""Every ${VAR} / ${VAR:-default} reference in a single string."""
if not isinstance(text, str):
return []
return [EnvRef(m.group(1), m.group(2), field) for m in _ENV_REF_RE.finditer(text)]
def is_env_ref(value: str) -> bool:
"""True when the value contains at least one ${VAR} reference.
Used to keep placeholders OUT of secret masking: `${API_KEY}` under a
secret-looking key is a reference, not a secret, and masking it to dots
would hide the one distinction the user needs to see.
"""
return bool(find_env_refs(value))
def server_env_refs(data: dict) -> list[EnvRef]:
"""Every env reference in a server definition, tagged with its field.
Only inspects the fields Claude Code actually expands; a ${VAR} written
into some other key is not a reference and shouldn't be reported as one.
"""
out: list[EnvRef] = []
if not isinstance(data, dict):
return out
for field in ENV_REF_FIELDS:
value = data.get(field)
if isinstance(value, str):
out.extend(find_env_refs(value, field))
elif isinstance(value, list):
for item in value:
out.extend(find_env_refs(item, field))
elif isinstance(value, dict):
for v in value.values():
out.extend(find_env_refs(v, field))
return out
def expand_env_refs(text: str, environ: dict | None = None) -> str:
"""Expand ${VAR} / ${VAR:-default} the way Claude Code documents it.
Provided for previewing what the client will do -- BCC never writes the
expanded form back to the config. Unset with no default is left as the
literal ${VAR} text, matching Claude Code: the config still loads and the
unexpanded text is passed through.
"""
if not isinstance(text, str):
return text
env = os.environ if environ is None else environ
def repl(m: re.Match) -> str:
name, default = m.group(1), m.group(2)
if name in env:
return env[name]
return default if default is not None else m.group(0)
return _ENV_REF_RE.sub(repl, text)
def unresolved_env_refs(data: dict, environ: dict | None = None) -> list[EnvRef]:
"""References that would not resolve: variable unset AND no default.
Best-effort by nature -- BCC's environment isn't necessarily the client's,
so this warns rather than blocks, and the warning text says so.
"""
env = os.environ if environ is None else environ
return [r for r in server_env_refs(data) if not r.has_default and r.name not in env]
def client_expands_env_refs(profile: Profile) -> bool:
"""Whether the client behind `profile` expands ${VAR} itself.
Claude Code does, in command/args/env/url/headers, for both project
`.mcp.json` and user-scope `~/.claude.json`. Claude Desktop has no
documented support, so a reference there reaches the server as literal
text -- which surfaces as a confusing auth failure rather than an obvious
config error, hence the warning.
Reads the capability straight off the profile's ClientSpec; the two Claude
specs carry the documented answer (Code yes, Desktop no).
"""
return profile.client.expands_env_refs
def env_ref_warnings(
data: dict, profile: Profile | None = None, environ: dict | None = None
) -> list[str]:
"""Advisory warnings about env references in one server definition.
Two distinct problems, deliberately worded differently:
- the target client won't expand them at all (Claude Desktop)
- the client will expand them, but a variable looks unset here
"""
refs = server_env_refs(data)
if not refs:
return []
if profile is not None and not client_expands_env_refs(profile):
names = ", ".join(sorted({f"${{{r.name}}}" for r in refs}))
return [
f"{names} will NOT be expanded by Claude Desktop -- it has no "
f"documented support for variable references, so the server "
f"receives the literal text. Use a real value here, or move this "
f"server to a Claude Code config."
]
missing = unresolved_env_refs(data, environ)
if not missing:
return []
names = ", ".join(sorted({r.name for r in missing}))
return [
f"{names} is not set in this environment and has no ':-default'. "
f"Claude Code will pass the reference through unexpanded. "
f"(Checked against BCC's environment, which may differ from the "
f"client's.)"
]
def is_secret_key(name: str) -> bool: def is_secret_key(name: str) -> bool:
"""Does this env-var / header / flag name look like it holds a secret?""" """Does this env-var / header / flag name look like it holds a secret?"""
return bool(_SECRET_KEY_RE.search(name or "")) return bool(_SECRET_KEY_RE.search(name or ""))
@@ -1202,17 +1853,22 @@ def redact_args(args: list[str]) -> list[str]:
--api-key=abc123 -> --api-key=•••••••• (inline flag=value) --api-key=abc123 -> --api-key=•••••••• (inline flag=value)
ghp_abc123 -> •••••••• (well-known token prefix) ghp_abc123 -> •••••••• (well-known token prefix)
Everything else passes through untouched. Everything else passes through untouched.
${VAR} references are left visible: they name a secret rather than being
one, and hiding them would obscure the difference between "this config
leaks a token" and "this config points at one" (#76).
""" """
out: list[str] = [] out: list[str] = []
mask_next = False mask_next = False
for a in args: for a in args:
s = str(a) s = str(a)
if mask_next: if mask_next:
out.append(MASK)
mask_next = False mask_next = False
out.append(s if is_env_ref(s) else MASK)
continue continue
if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]): if s.startswith("-") and "=" in s and is_secret_key(s.split("=", 1)[0]):
out.append(s.split("=", 1)[0] + "=" + MASK) flag, value = s.split("=", 1)
out.append(f"{flag}={value}" if is_env_ref(value) else f"{flag}={MASK}")
continue continue
if s.startswith("-") and is_secret_key(s): if s.startswith("-") and is_secret_key(s):
out.append(s) out.append(s)
@@ -1239,6 +1895,11 @@ def args_secret_warning(data: dict) -> str | None:
args = [str(a) for a in (data.get("args") or [])] args = [str(a) for a in (data.get("args") or [])]
mask_next = False mask_next = False
for a in args: for a in args:
# A ${VAR} reference is the recommended fix for this very warning --
# continuing to warn after the user adopts it punishes the fix (#76).
if is_env_ref(a):
mask_next = False
continue
if mask_next: if mask_next:
mask_next = False mask_next = False
if not a.startswith("-"): if not a.startswith("-"):
@@ -1380,9 +2041,21 @@ def lint_server(name: str, data: dict) -> list[str]:
def lint_servers(servers: list[ServerEntry]) -> list[str]: def lint_servers(servers: list[ServerEntry]) -> list[str]:
"""Concatenate lint_server warnings across every entry, in order.""" """Concatenate lint_server warnings across every entry, in order.
Entries whose config value wasn't a JSON object at all are reported here
rather than in lint_server, which takes an already-dict `data` (#72).
"""
out: list[str] = [] out: list[str] = []
for s in servers: for s in servers:
if s.malformed:
nm = s.name.strip() or "(unnamed)"
out.append(
f"'{nm}': server definition is not an object "
f"(found {type(s.raw).__name__}) -- it is preserved as-is; "
f"edit it to replace it with a proper definition"
)
continue
out.extend(lint_server(s.name, s.data)) out.extend(lint_server(s.name, s.data))
return out return out
+1 -1
View File
@@ -1,5 +1,6 @@
# Runtime (also in requirements.txt) # Runtime (also in requirements.txt)
PySide6>=6.6 PySide6>=6.6
cryptography>=42.0 # catalog signature verification (bcc_core) + release checksum signing
# Build / packaging # Build / packaging
pyinstaller>=6.0 pyinstaller>=6.0
@@ -8,4 +9,3 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
# Test / lint # Test / lint
pytest>=8.0 pytest>=8.0
ruff>=0.6 ruff>=0.6
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
+1
View File
@@ -1 +1,2 @@
PySide6>=6.6 PySide6>=6.6
cryptography>=42.0 # bcc_core imports it at load (catalog signature verification)
+689
View File
@@ -1,6 +1,7 @@
"""Pytest port of the original test_core.py script (same 23 behaviours, now """Pytest port of the original test_core.py script (same 23 behaviours, now
proper test functions with tmp_path/monkeypatch fixtures).""" proper test functions with tmp_path/monkeypatch fixtures)."""
import dataclasses
import json import json
import os import os
import re import re
@@ -2370,3 +2371,691 @@ def test_config_has_unfilled_placeholders_false_after_fill():
def test_config_has_unfilled_placeholders_checks_env_too(): def test_config_has_unfilled_placeholders_checks_env_too():
cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": "<GRAFANA_URL>"}} cfg = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": "<GRAFANA_URL>"}}
assert c.config_has_unfilled_placeholders(cfg) is True assert c.config_has_unfilled_placeholders(cfg) is True
# --------------------------------------------------------------------------- #
# #72 -- a server value that isn't a JSON object must not take the load down
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize("bad", ["not-a-dict", 123, ["a", "b"], None, True, 1.5])
def test_extract_servers_survives_non_dict_server_value(bad):
entries = c.extract_servers({"mcpServers": {"foo": bad}})
assert len(entries) == 1
assert entries[0].name == "foo"
assert entries[0].data == {}
assert entries[0].malformed is True
assert entries[0].raw == bad
def test_extract_servers_marks_only_the_bad_entry():
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
by_name = {e.name: e for e in c.extract_servers(cfg)}
assert by_name["good"].malformed is False
assert by_name["good"].data == {"command": "npx"}
assert by_name["bad"].malformed is True
def test_extract_servers_handles_malformed_disabled_entry():
entries = c.extract_servers({c.DISABLED_KEY: {"parked": ["nope"]}})
assert entries[0].enabled is False
assert entries[0].malformed is True
def test_malformed_entry_round_trips_through_save_unchanged():
"""The cardinal rule: never silently delete what the user had on disk."""
cfg = {"mcpServers": {"good": {"command": "npx"}, "bad": "oops"}}
servers = c.extract_servers(cfg)
out = c.apply_servers(dict(cfg), servers)
assert out["mcpServers"]["bad"] == "oops"
assert out["mcpServers"]["good"] == {"command": "npx"}
def test_editing_a_malformed_entry_retires_the_raw_value():
entry = c.extract_servers({"mcpServers": {"bad": "oops"}})[0]
entry.set_data({"command": "npx"})
assert entry.malformed is False
assert entry.config_value() == {"command": "npx"}
assert c.apply_servers({}, [entry])["mcpServers"]["bad"] == {"command": "npx"}
def test_lint_reports_the_malformed_entry_by_name():
servers = c.extract_servers({"mcpServers": {"bad": "oops"}})
warnings = c.lint_servers(servers)
assert len(warnings) == 1
assert "'bad'" in warnings[0]
assert "not an object" in warnings[0]
assert "str" in warnings[0]
def test_lint_still_reports_normal_warnings_alongside_malformed():
cfg = {"mcpServers": {"bad": "oops", "sloppy": {"command": "npx", "args": "one two"}}}
warnings = c.lint_servers(c.extract_servers(cfg))
assert any("not an object" in w for w in warnings)
assert any("'args' should be a list" in w for w in warnings)
# --------------------------------------------------------------------------- #
# #73 -- the stale-file merge must not discard BCC-authored keys
# --------------------------------------------------------------------------- #
def test_carry_owned_keys_moves_sets_onto_the_reloaded_config():
local = {"mcpServers": {}, c.SETS_KEY: {"work": ["a", "b"]}}
fresh = {"mcpServers": {"external": {"command": "npx"}}}
contested = c.carry_owned_keys(local, fresh)
assert contested == []
assert fresh[c.SETS_KEY] == {"work": ["a", "b"]}
assert fresh["mcpServers"] == {"external": {"command": "npx"}}
def test_carry_owned_keys_reports_a_genuine_conflict():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {c.SETS_KEY: {"work": ["a", "b"]}}
assert c.carry_owned_keys(local, fresh) == [c.SETS_KEY]
assert fresh[c.SETS_KEY] == {"work": ["a"]} # local wins: BCC owns the key
def test_carry_owned_keys_is_quiet_when_both_sides_agree():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {c.SETS_KEY: {"work": ["a"]}}
assert c.carry_owned_keys(local, fresh) == []
def test_carry_owned_keys_leaves_disk_alone_when_absent_locally():
"""Can't distinguish 'deleted my last set' from 'never had sets'; keep theirs."""
fresh = {c.SETS_KEY: {"remote": ["a"]}}
assert c.carry_owned_keys({}, fresh) == []
assert fresh[c.SETS_KEY] == {"remote": ["a"]}
def test_carry_owned_keys_deep_copies_so_later_edits_do_not_leak():
local = {c.SETS_KEY: {"work": ["a"]}}
fresh = {}
c.carry_owned_keys(local, fresh)
local[c.SETS_KEY]["work"].append("b")
assert fresh[c.SETS_KEY] == {"work": ["a"]}
def test_merge_flow_preserves_sets_and_external_servers(tmp_path):
"""End-to-end shape of the Merge & save path that lost sets in #73."""
path = tmp_path / "claude.json"
path.write_text(json.dumps({"mcpServers": {"old": {"command": "old"}}}))
# BCC loads, user saves a named set and edits servers in memory.
local = c.load_config(path)
servers = c.extract_servers(local)
c.save_server_set(local, "work", servers)
# Something else rewrites the file underneath us.
path.write_text(json.dumps({"mcpServers": {"external": {"command": "new"}}, "other": 1}))
# Merge & save: reload disk, carry BCC keys, re-apply the user's servers.
fresh = c.load_config(path)
c.carry_owned_keys(local, fresh)
c.apply_servers(fresh, servers)
c.write_config(path, fresh)
saved = c.load_config(path)
assert saved[c.SETS_KEY] == {"work": ["old"]} # the set survived
assert saved["other"] == 1 # unrelated external key preserved
assert "old" in saved["mcpServers"] # user's servers re-applied
def test_null_server_value_is_malformed_not_mistaken_for_absent():
"""`{"mcpServers": {"foo": null}}` is legal JSON and a real malformed case,
so None must not double as the 'nothing here' sentinel."""
entry = c.extract_servers({"mcpServers": {"foo": None}})[0]
assert entry.malformed is True
assert entry.raw is None
assert c.apply_servers({}, [entry])["mcpServers"]["foo"] is None
def test_a_normal_entry_is_not_malformed():
entry = c.extract_servers({"mcpServers": {"foo": {"command": "npx"}}})[0]
assert entry.malformed is False
assert entry.raw is c.NO_RAW
# --------------------------------------------------------------------------- #
# #75 -- theming
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"setting,system_dark,expected",
[
(c.THEME_DARK, False, "dark"),
(c.THEME_DARK, True, "dark"),
(c.THEME_LIGHT, False, "light"),
(c.THEME_LIGHT, True, "light"),
(c.THEME_SYSTEM, True, "dark"),
(c.THEME_SYSTEM, False, "light"),
],
)
def test_resolve_theme_covers_every_setting_and_appearance(setting, system_dark, expected):
assert c.resolve_theme(setting, system_dark) == expected
@pytest.mark.parametrize("junk", ["", "solarized", None, "DARK", 3])
def test_resolve_theme_falls_back_to_following_the_system(junk):
"""A hand-edited or future QSettings value should follow the desktop,
not pin a fixed theme."""
assert c.resolve_theme(junk, True) == "dark"
assert c.resolve_theme(junk, False) == "light"
def test_palette_for_known_names():
assert c.palette_for("dark") is c.DARK_PALETTE
assert c.palette_for("light") is c.LIGHT_PALETTE
def test_palette_for_unknown_name_falls_back_to_dark():
assert c.palette_for("chartreuse") is c.DARK_PALETTE
def test_dark_palette_is_unchanged_from_the_shipped_look():
"""v1.3.0 shipped these exact colours; adding a light theme must not
quietly restyle the dark one."""
p = c.DARK_PALETTE
assert (p.accent, p.bg, p.panel, p.panel_2) == ("#f97316", "#1b1d23", "#23262e", "#2b2f39")
assert (p.text, p.muted, p.border) == ("#e7e9ee", "#9aa0ad", "#3a3f4b")
assert (p.good, p.bad, p.warn, p.remote) == ("#4ade80", "#f87171", "#fbbf24", "#60a5fa")
assert (p.on_accent, p.disabled_bg, p.mono_bg) == ("#1a1205", "#202229", "#16181d")
def test_both_palettes_define_every_slot():
"""A missing slot should fail here rather than render a broken window."""
for pal in (c.DARK_PALETTE, c.LIGHT_PALETTE):
for f in dataclasses.fields(c.Palette):
value = getattr(pal, f.name)
assert value, f"{pal.name}.{f.name} is empty"
if f.name != "name":
assert re.fullmatch(r"#[0-9a-fA-F]{6}", value), f"{pal.name}.{f.name}={value!r}"
@pytest.mark.parametrize("pal_name", ["dark", "light"])
@pytest.mark.parametrize("slot", ["text", "muted", "good", "bad", "warn", "remote", "accent"])
def test_palette_meets_contrast_on_panel(pal_name, slot):
"""Every colour drawn as text/glyph must clear WCAG AA (4.5:1) against the
surface it sits on. The light palette's semantic colours are NOT the dark
ones lightened -- #4ade80 sits near 1.7:1 on white -- so this guards
against someone 'harmonising' them back toward the dark hues."""
pal = c.palette_for(pal_name)
assert c.contrast_ratio(getattr(pal, slot), pal.panel) >= 4.5
@pytest.mark.parametrize("pal_name", ["dark", "light"])
def test_on_accent_is_legible_against_the_accent_fill(pal_name):
"""Primary buttons and selected rows draw on_accent on top of accent."""
pal = c.palette_for(pal_name)
assert c.contrast_ratio(pal.on_accent, pal.accent) >= 4.5
def test_contrast_ratio_endpoints():
assert c.contrast_ratio("#000000", "#ffffff") == pytest.approx(21.0, abs=0.01)
assert c.contrast_ratio("#123456", "#123456") == pytest.approx(1.0, abs=0.001)
assert c.contrast_ratio("#ffffff", "#000000") == pytest.approx(21.0, abs=0.01)
def test_relative_luminance_extremes():
assert c.relative_luminance("#000000") == pytest.approx(0.0)
assert c.relative_luminance("#ffffff") == pytest.approx(1.0)
def test_stylesheet_builder_has_no_hardcoded_colours():
"""Every colour in the QSS must come from the palette.
Three near-black literals used to be inlined here (#1a1205, #202229,
#16181d). Harmless with one theme; with two, they silently render dark
chrome on a light window. Reads the source rather than importing bcc,
which needs PySide6.
"""
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
start = src.index("def build_stylesheet")
body = src[start : src.index("def apply_palette")]
assert re.findall(r"#[0-9a-fA-F]{6}", body) == []
def test_every_palette_slot_is_consumed():
"""A slot added to Palette but never wired up is dead weight.
Checks for `p.<slot>` anywhere in bcc.py, which covers both the QSS and
apply_palette's global bindings -- not every slot belongs in the
stylesheet (`good` and `remote` feed the inline status dots via
STATUS_COLORS/HEALTH_COLORS, never the QSS). This won't catch a slot bound
to a global that nothing then uses; it does catch the common mistake of
extending the dataclass and forgetting to plumb it through.
"""
src = (Path(__file__).resolve().parent.parent / "bcc.py").read_text(encoding="utf-8")
for f in dataclasses.fields(c.Palette):
if f.name == "name":
continue
assert f"p.{f.name}" in src, f"palette slot {f.name!r} is never consumed"
# --------------------------------------------------------------------------- #
# #78/#79 -- update notice: when to show it, and what it says
# --------------------------------------------------------------------------- #
def test_update_notice_when_a_newer_release_exists():
n = c.update_notice("1.2.0", {"version": "v1.3.0", "url": "https://example.test/rel"})
assert n is not None
assert n["version"] == "v1.3.0"
assert n["url"] == "https://example.test/rel"
assert "1.3.0" in n["text"] and "1.2.0" in n["text"]
def test_update_notice_is_silent_when_current():
assert c.update_notice("1.3.0", {"version": "v1.3.0"}) is None
assert c.update_notice("1.4.0", {"version": "v1.3.0"}) is None
@pytest.mark.parametrize("bad", [None, {}, {"version": ""}, {"version": None}, {"version": 3}, []])
def test_update_notice_is_silent_on_a_failed_or_malformed_check(bad):
"""fetch_latest_release returns None on any failure; a half-formed payload
must not produce a notice pointing at nothing."""
assert c.update_notice("1.0.0", bad) is None
def test_update_notice_falls_back_to_the_releases_page_without_a_url():
n = c.update_notice("1.0.0", {"version": "v2.0.0"})
assert n["url"] == c.RELEASES_URL
def test_update_notice_names_no_menu_path():
"""The old status-line text said 'Help > About to view it', which is wrong
on macOS -- Qt moves the About action into the application menu (#79). The
notice carries its own action, so it must not describe a menu path."""
n = c.update_notice("1.0.0", {"version": "v2.0.0"})
lowered = n["text"].lower()
for phrase in ("help", "about", "menu", "▸", ">"):
assert phrase not in lowered, f"notice text should not reference {phrase!r}"
def test_update_notice_handles_the_v_prefix_consistently():
assert c.update_notice("1.2.0", {"version": "1.3.0"}) is not None
assert c.update_notice("v1.2.0", {"version": "v1.3.0"}) is not None
assert c.update_notice("1.3.0", {"version": "v1.3.0"}) is None
def test_update_notice_renders_both_versions_the_same_way():
"""Tags carry a 'v' prefix, __version__ doesn't -- don't show both forms
in one sentence."""
n = c.update_notice("1.2.0", {"version": "v1.3.0"})
assert "v1.3.0" not in n["text"]
assert "1.3.0" in n["text"] and "1.2.0" in n["text"]
# the machine-readable field keeps the real tag
assert n["version"] == "v1.3.0"
# --------------------------------------------------------------------------- #
# #76 -- ${VAR} references. Semantics mirror Claude Code's documented
# behaviour: ${VAR} and ${VAR:-default}, expanded in command/args/env/url/
# headers, and an unset variable with no default left as literal text.
# --------------------------------------------------------------------------- #
def test_find_env_refs_plain_and_defaulted():
refs = c.find_env_refs("${A} and ${B:-fallback}")
assert [(r.name, r.default) for r in refs] == [("A", None), ("B", "fallback")]
@pytest.mark.parametrize("text", ["${}", "${1BAD}", "$NOTBRACED", "{NOPE}", "plain", "$${X"])
def test_find_env_refs_ignores_non_references(text):
assert c.find_env_refs(text) == []
def test_find_env_refs_allows_an_empty_default():
"""`${VAR:-}` is a documented way to say 'blank if unset'."""
refs = c.find_env_refs("${A:-}")
assert refs[0].default == ""
assert refs[0].has_default is True
def test_server_env_refs_covers_all_five_documented_fields():
data = {
"command": "${BIN}",
"args": ["--x", "${ARG}"],
"env": {"K": "${ENVV}"},
"url": "${URL}/mcp",
"headers": {"Authorization": "Bearer ${HDR}"},
}
found = {(r.name, r.field) for r in c.server_env_refs(data)}
assert found == {
("BIN", "command"),
("ARG", "args"),
("ENVV", "env"),
("URL", "url"),
("HDR", "headers"),
}
def test_server_env_refs_ignores_unexpanded_fields():
"""Claude Code expands five fields; a ${VAR} elsewhere isn't a reference."""
assert c.server_env_refs({"description": "${NOPE}", "timeout": "${ALSO_NO}"}) == []
def test_expand_env_refs_matches_documented_semantics():
env = {"SET": "value"}
assert c.expand_env_refs("${SET}", env) == "value"
assert c.expand_env_refs("${MISSING:-dflt}", env) == "dflt"
assert c.expand_env_refs("${SET:-dflt}", env) == "value"
# unset with no default: left as literal text, exactly as Claude Code does
assert c.expand_env_refs("${MISSING}", env) == "${MISSING}"
def test_expand_env_refs_handles_several_in_one_string():
assert c.expand_env_refs("${A}/${B:-two}/${C}", {"A": "one"}) == "one/two/${C}"
def test_unresolved_env_refs_only_flags_unset_without_default():
data = {"env": {"A": "${SET}", "B": "${UNSET}", "C": "${OTHER:-has_default}"}}
assert [r.name for r in c.unresolved_env_refs(data, {"SET": "x"})] == ["UNSET"]
# --- the two interactions that were backwards for this feature ------------
def test_placeholder_under_a_secret_key_is_not_masked():
"""A ${VAR} names a secret rather than being one. Masking it would make a
reference indistinguishable from a stored credential."""
assert c.should_mask_value("API_KEY", "${API_KEY}") is False
assert c.should_mask_value("API_KEY", "ghp_realsecret") is True
assert c.should_mask_value("NOT_SECRET", "${API_KEY}") is False
def test_redacted_display_keeps_placeholders_but_masks_real_secrets():
out = c._redact_server_data({"env": {"API_KEY": "${API_KEY}", "TOKEN": "ghp_real"}})
assert out["env"]["API_KEY"] == "${API_KEY}"
assert out["env"]["TOKEN"] == c.MASK
def test_redact_args_keeps_placeholders_visible():
assert c.redact_args(["--token", "${GH_TOKEN}"]) == ["--token", "${GH_TOKEN}"]
assert c.redact_args(["--api-key=${K}"]) == ["--api-key=${K}"]
# real secrets still masked
assert c.redact_args(["--token", "ghp_real"]) == ["--token", c.MASK]
assert c.redact_args(["--api-key=sk-real"]) == [f"--api-key={c.MASK}"]
def test_args_secret_warning_is_silenced_by_a_placeholder():
"""Moving a token into ${VAR} is the recommended fix for this warning --
still warning afterwards would punish the fix."""
assert c.args_secret_warning({"args": ["--token", "ghp_real"]}) is not None
assert c.args_secret_warning({"args": ["--token", "${GH_TOKEN}"]}) is None
def test_args_secret_warning_still_fires_on_the_arg_after_a_placeholder():
"""A placeholder must clear the pending-flag state, not blanket-suppress."""
assert c.args_secret_warning({"args": ["${SAFE}", "--token", "ghp_real"]}) is not None
# --- per-client gating ----------------------------------------------------
def _profile(path):
return c.Profile(label="p", path=Path(path), config_exists=True)
def test_claude_code_profiles_expand_references():
assert c.client_expands_env_refs(_profile(Path.home() / ".claude.json")) is True
assert c.client_expands_env_refs(_profile("/repo/.mcp.json")) is True
def test_claude_desktop_profile_does_not_expand_references():
desktop = _profile(c.app_support_base() / "Claude" / c.CONFIG_FILENAME)
assert c.client_expands_env_refs(desktop) is False
def test_desktop_profile_warns_that_references_are_literal():
data = {"env": {"API_KEY": "${API_KEY}"}}
desktop = _profile(c.app_support_base() / "Claude" / c.CONFIG_FILENAME)
warnings = c.env_ref_warnings(data, desktop, {"API_KEY": "set"})
assert len(warnings) == 1
assert "NOT be expanded" in warnings[0]
assert "${API_KEY}" in warnings[0]
def test_claude_code_profile_warns_only_about_unset_variables():
code = _profile(Path.home() / ".claude.json")
data = {"env": {"A": "${UNSET_ONE}"}}
assert c.env_ref_warnings(data, code, {}) != []
assert c.env_ref_warnings(data, code, {"UNSET_ONE": "x"}) == []
# a default means it always resolves
assert c.env_ref_warnings({"env": {"A": "${X:-d}"}}, code, {}) == []
def test_no_references_means_no_warnings():
assert c.env_ref_warnings({"command": "npx", "args": ["-y", "pkg"]}, None) == []
# --------------------------------------------------------------------------- #
# Client adapters (issue #5 — cross-client support, phase 1)
#
# The refactor's promise is twofold: (1) the two Claude clients behave exactly
# as before, and (2) the ClientSpec seam is real — a client with a different
# servers key and a different per-server shape flows through the same pipeline.
# A synthetic "VS Code-like" spec stands in for the phase-2 client so the
# abstraction is proven now, before anything depends on it.
# --------------------------------------------------------------------------- #
def test_claude_specs_are_registered_and_mcpservers_shaped():
assert c.CLAUDE_DESKTOP.servers_key == "mcpServers"
assert c.CLAUDE_CODE.servers_key == "mcpServers"
assert c.CLAUDE_DESKTOP.disabled_key == c.DISABLED_KEY
assert c.CLAUDE_CODE.disabled_key == c.DISABLED_KEY
# capabilities the old inline filename checks used to compute
assert c.CLAUDE_DESKTOP.expands_env_refs is False
assert c.CLAUDE_CODE.expands_env_refs is True
assert c.CLAUDE_DESKTOP.supports_restart is True
assert c.CLAUDE_CODE.supports_restart is False
assert set(c.CLIENT_SPECS) == {c.CLAUDE_DESKTOP, c.CLAUDE_CODE}
assert c.DEFAULT_CLIENT is c.CLAUDE_DESKTOP
def test_client_by_key_round_trips_and_misses():
assert c.client_by_key("claude_desktop") is c.CLAUDE_DESKTOP
assert c.client_by_key("claude_code") is c.CLAUDE_CODE
assert c.client_by_key("nope") is None
def test_resolve_client_matches_the_old_filename_rule():
assert c.resolve_client("/x/Claude/claude_desktop_config.json") is c.CLAUDE_DESKTOP
assert c.resolve_client(Path.home() / ".claude.json") is c.CLAUDE_CODE
assert c.resolve_client("/repo/.mcp.json") is c.CLAUDE_CODE
assert c.resolve_client(Path.home() / ".claude" / "settings.json") is c.CLAUDE_CODE
def test_profile_auto_resolves_client_from_path():
desktop = c.Profile(
label="Claude", path="/x/Claude/claude_desktop_config.json", config_exists=True
)
code = c.Profile(label="Claude Code", path="/home/me/.claude.json", config_exists=True)
assert desktop.client is c.CLAUDE_DESKTOP
assert code.client is c.CLAUDE_CODE
def test_profile_honours_an_explicit_client():
# An explicit spec is not overridden by the path-based resolver.
p = c.Profile(
label="odd",
path="/somewhere/claude_desktop_config.json",
config_exists=True,
client=c.CLAUDE_CODE,
)
assert p.client is c.CLAUDE_CODE
def test_desktop_gating_and_env_expansion_read_off_the_spec():
desktop = c.Profile(label="d", path="/x/Claude/claude_desktop_config.json", config_exists=True)
code = c.Profile(label="c", path=Path.home() / ".claude.json", config_exists=True)
assert c.profile_targets_claude_desktop(desktop) is True
assert c.profile_targets_claude_desktop(code) is False
assert c.client_expands_env_refs(desktop) is False
assert c.client_expands_env_refs(code) is True
def test_extract_and_apply_default_spec_is_unchanged():
# No spec argument must behave byte-for-byte like the pre-refactor code.
cfg = {"mcpServers": {"a": {"command": "x"}}, "_disabledMcpServers": {"b": {"command": "y"}}}
servers = c.extract_servers(cfg)
assert {(s.name, s.enabled) for s in servers} == {("a", True), ("b", False)}
out = c.apply_servers({}, servers)
assert out == {
"mcpServers": {"a": {"command": "x"}},
"_disabledMcpServers": {"b": {"command": "y"}},
}
# A stand-in for the phase-2 VS Code adapter: different top-level key
# ("servers"), a different disabled key, and a per-server shape that carries a
# `type` field the internal model doesn't. entry_to/from_internal are the only
# things it overrides — proving that's the whole extension point.
class _FakeVSCode(c.ClientSpec):
def entry_to_internal(self, value):
if not isinstance(value, dict):
return value
return {k: v for k, v in value.items() if k != "type"}
def entry_from_internal(self, data):
if not isinstance(data, dict):
return data
return {"type": "stdio", **data}
_VSCODE = _FakeVSCode(
key="vscode_fake",
label="VS Code (test)",
servers_key="servers",
disabled_key="_bccDisabledServers",
)
def test_extract_reads_a_custom_servers_key_and_translates_shape():
cfg = {"servers": {"a": {"type": "stdio", "command": "x", "args": ["-y"]}}}
servers = c.extract_servers(cfg, _VSCODE)
assert len(servers) == 1
# the `type` field was translated out of the internal model
assert servers[0].data == {"command": "x", "args": ["-y"]}
def test_apply_writes_a_custom_key_translates_back_and_keeps_other_keys():
original = {"servers": {"old": {"type": "stdio", "command": "z"}}, "keepMe": {"x": 1}}
servers = c.extract_servers(original, _VSCODE)
out = c.apply_servers(original, servers, _VSCODE)
# round-trips through the custom key with the shape restored
assert out["servers"] == {"old": {"type": "stdio", "command": "z"}}
# the cardinal rule generalises: mcpServers is never introduced, and every
# unrelated key survives verbatim
assert "mcpServers" not in out
assert out["keepMe"] == {"x": 1}
def test_apply_uses_the_custom_disabled_key():
servers = [
c.ServerEntry("on", {"command": "a"}, True),
c.ServerEntry("off", {"command": "b"}, False),
]
out = c.apply_servers({}, servers, _VSCODE)
assert out["servers"] == {"on": {"type": "stdio", "command": "a"}}
assert out["_bccDisabledServers"] == {"off": {"type": "stdio", "command": "b"}}
assert c.DISABLED_KEY not in out
def test_spec_with_no_disabled_key_drops_disabled_and_never_parks():
no_park = c.ClientSpec(
key="nopark", label="No Park", servers_key="mcpServers", disabled_key=None
)
servers = [
c.ServerEntry("on", {"command": "a"}, True),
c.ServerEntry("off", {"command": "b"}, False),
]
out = c.apply_servers({}, servers, no_park)
assert out == {"mcpServers": {"on": {"command": "a"}}}
assert c.DISABLED_KEY not in out
assert no_park.section_keys() == ("mcpServers",)
def test_section_keys_reports_both_when_a_disabled_key_exists():
assert c.CLAUDE_DESKTOP.section_keys() == ("mcpServers", c.DISABLED_KEY)
assert _VSCODE.section_keys() == ("servers", "_bccDisabledServers")
def test_malformed_entry_round_trips_through_the_default_spec():
# #72's non-object server value must still be preserved verbatim on save.
cfg = {"mcpServers": {"bad": "oops", "good": {"command": "x"}}}
servers = c.extract_servers(cfg)
assert any(s.malformed and s.name == "bad" for s in servers)
out = c.apply_servers({}, servers)
assert out["mcpServers"]["bad"] == "oops"
def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
loaded = {"servers": {"a": {"type": "stdio", "command": "x", "env": {"API_KEY": "sekret"}}}}
sections = c._server_sections(loaded, _VSCODE)
assert "servers" in sections
assert "mcpServers" not in sections
# secret masking still applies through the custom key
assert sections["servers"]["a"]["env"]["API_KEY"] == c.MASK
disk = {"servers": {"a": {"type": "stdio", "command": "CHANGED"}}}
p = tmp_path / "vscode.json"
p.write_text(json.dumps(disk), encoding="utf-8")
changed_keys, diff = c.external_change_summary(loaded, p, _VSCODE)
assert "servers" in changed_keys
assert diff # a server-section change under the custom key is diffed
# --------------------------------------------------------------------------- #
# Project profile label disambiguation (issue #74)
# --------------------------------------------------------------------------- #
def test_disambiguate_labels_no_collision_uses_basename():
dirs = [Path("/home/me/work/api"), Path("/home/me/work/web")]
labels = c.disambiguate_project_labels(dirs)
assert labels[Path("/home/me/work/api")] == "Project: api"
assert labels[Path("/home/me/work/web")] == "Project: web"
def test_disambiguate_labels_widens_only_colliding_basenames():
dirs = [
Path("/home/me/work/app"),
Path("/home/me/personal/app"),
Path("/home/me/notes"),
]
labels = c.disambiguate_project_labels(dirs)
# the two "app"s widen by one parent; the unique "notes" stays plain
assert labels[Path("/home/me/work/app")] == "Project: work/app"
assert labels[Path("/home/me/personal/app")] == "Project: personal/app"
assert labels[Path("/home/me/notes")] == "Project: notes"
def test_disambiguate_labels_widens_further_when_parent_also_collides():
dirs = [Path("/a/x/app"), Path("/b/x/app")]
labels = c.disambiguate_project_labels(dirs)
assert labels[Path("/a/x/app")] == "Project: a/x/app"
assert labels[Path("/b/x/app")] == "Project: b/x/app"
def _write_project(tmp_path, name, mcp_content):
d = tmp_path / name
d.mkdir(parents=True)
if mcp_content is not None:
(d / ".mcp.json").write_text(mcp_content, encoding="utf-8")
return d
def _claude_json_with_projects(tmp_path, dirs):
cj = tmp_path / ".claude.json"
cj.write_text(json.dumps({"projects": {str(d): {} for d in dirs}}), encoding="utf-8")
return cj
def test_discover_project_configs_disambiguates_same_basename(tmp_path):
d1 = _write_project(tmp_path / "work", "app", '{"mcpServers": {}}')
d2 = _write_project(tmp_path / "personal", "app", '{"mcpServers": {}}')
cj = _claude_json_with_projects(tmp_path, [d1, d2])
profiles = c.discover_project_configs(cj)
labels = sorted(p.label for p in profiles)
assert labels == ["Project: personal/app", "Project: work/app"]
def test_discover_project_configs_skips_non_object_and_garbage(tmp_path):
good = _write_project(tmp_path, "good", '{"mcpServers": {}}')
array = _write_project(tmp_path, "arr", "[1, 2, 3]")
garbage = _write_project(tmp_path, "junk", "not json at all")
missing = tmp_path / "nofile"
missing.mkdir()
cj = _claude_json_with_projects(tmp_path, [good, array, garbage, missing])
profiles = c.discover_project_configs(cj)
paths = {str(p.path) for p in profiles}
assert str(good / ".mcp.json") in paths
assert str(array / ".mcp.json") not in paths
assert str(garbage / ".mcp.json") not in paths
assert str(missing / ".mcp.json") not in paths