Author SHA1 Message Date
the_og cfe05b4324 Merge branch 'main' into feat/10-browse-dialog
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 23s
CI / Lint (ruff) (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 / Catalog signature (pull_request) Successful in 7s
2026-07-12 21:30:19 -04:00
Cowork Supervisor dffa0e152f Browse catalog dialog (issue #10 phase 2)
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.12 / windows-latest) (pull_request) Failing after 23s
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 7s
Adds the user-facing half of the server catalog: a searchable, signed
catalog browser wired to the existing paste/import path.

bcc_core.py (pure, GUI-free, per the design comment on #10):
- CATALOG_CATEGORY_GROUPS / catalog_category_group(): collapses the
  raw taxonomy to the 7 UI chips (unknown categories fall back to
  Other, never drop an entry).
- catalog_entry_matches_query() / filter_catalog_entries() /
  catalog_entries_in_group(): search-as-you-type + category chip
  filtering over catalog entries.
- format_freshness_hint(): last_release ISO date -> "Last updated N
  months/years ago", '' when missing/unparseable/future.
- first_unfilled_focus_target(): finds the first <PLACEHOLDER> arg or
  blank env_required key so the GUI can focus it after Add.
- load_bundled_catalog_entries(): reads+verifies+validates the
  bundled catalog.json/.sig pair, returns servers or an EMPTY list on
  ANY failure -- the load-bearing guarantee behind the dialog's empty
  state.
- Bug fix: catalog_entry_to_paste_json() only copied config.env,
  ignoring env_required -- the field where 9 of the 19 seed entries
  (postgres, github, notion, obsidian, brave-search, tavily,
  home-assistant, n8n, grafana) actually declare their secret var
  names. Add would have silently added these servers with no env
  field for the user to fill in. Now env_required keys are seeded as
  empty-string placeholders unless config.env already sets them.

bcc.py:
- BrowseCatalogDialog: search box, category chips, list, detail pane
  (description/notes/homepage/freshness hint/verbatim command
  preview), per-tier action (Add for basic, Open setup docs for
  link-only). Every catalog-derived string goes through plain_label()
  (Qt.PlainText + html.escape, matching catalog_console.py's
  approach) or an inherently-plain QPlainTextEdit for the command
  preview -- catalog.json takes community PRs, so every field is
  attacker-influenceable.
- "Browse catalog…" button next to Paste JSON.
- ServerEditor.focus_target(): selects the first unfilled arg line or
  opens the env-table cell editor for the first blank env row.
- Save-time placeholder guard: warns (Yes/No, defaults No) when any
  server still has an unfilled <PLACEHOLDER>; never blocks a
  deliberate save, never saves one unnoticed.

bcc.spec: bundle data/catalog.json.sig alongside catalog.json -- the
signature file was missing from datas, so a frozen build would have
had a catalog.json with no matching .sig for resolve_catalog() to
verify against.

tests/test_core.py: +82 tests covering category-group mapping,
catalog search/filter, freshness-hint formatting (including the
month/year rounding boundary), first_unfilled_focus_target, the
catalog_entry_to_paste_json env_required fix (incl. a regression
against the real shipped postgres entry), and
load_bundled_catalog_entries under valid/tampered/wrong-key/missing-
file/invalid-but-signed conditions plus an end-to-end check against
the real data/catalog.json + .sig (19 entries).

GUI code (BrowseCatalogDialog, ServerEditor.focus_target,
MainWindow.browse_catalog) is unavoidably untested here -- PySide6
cannot import in this sandbox (missing libEGL/system GL libs) -- but
all the logic it depends on is pushed into bcc_core.py and covered
above.
2026-07-12 19:00:54 -04:00
7 changed files with 947 additions and 1843 deletions
+1 -3
View File
@@ -31,8 +31,6 @@ The codebase is split into two layers:
**`bcc_core.py`** — All logic with no GUI imports. Contains:
- `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…
- `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)
@@ -45,7 +43,7 @@ The codebase is split into two layers:
- `KeyValueTable` — reusable widget for env vars and headers
- `ConnTester(QThread)` — background thread for remote reachability tests
**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.
**The cardinal rule**: `apply_servers()` only ever writes to `mcpServers` and `_disabledMcpServers`. All other keys in the user's config are preserved verbatim and in their original order.
Disabled servers are parked under `_disabledMcpServers` (which Claude Desktop ignores) so they can be re-enabled without losing their definition.
+441 -434
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -31,7 +31,11 @@ a = Analysis(
["bcc.py"],
pathex=[],
binaries=[],
datas=[("icons", "icons"), ("data/catalog.json", "data")],
datas=[
("icons", "icons"),
("data/catalog.json", "data"),
("data/catalog.json.sig", "data"),
],
hiddenimports=[],
hookspath=[],
hooksconfig={},
+232 -770
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,5 @@
# Runtime (also in requirements.txt)
PySide6>=6.6
cryptography>=42.0 # catalog signature verification (bcc_core) + release checksum signing
# Build / packaging
pyinstaller>=6.0
@@ -9,3 +8,4 @@ pillow>=10.0 # generates icons/app.ico during CI (Windows build)
# Test / lint
pytest>=8.0
ruff>=0.6
cryptography>=42.0 # release checksum signing (scripts/sign_checksums.py)
-1
View File
@@ -1,2 +1 @@
PySide6>=6.6
cryptography>=42.0 # bcc_core imports it at load (catalog signature verification)
+267 -633
View File
@@ -1,7 +1,6 @@
"""Pytest port of the original test_core.py script (same 23 behaviours, now
proper test functions with tmp_path/monkeypatch fixtures)."""
import dataclasses
import json
import os
import re
@@ -2358,6 +2357,43 @@ def test_catalog_entry_to_paste_json_includes_env_when_present():
assert c.validate_catalog(malicious) != []
def test_catalog_entry_to_paste_json_seeds_env_required_keys():
# Regression: env_required is where the seed data actually keeps its
# secret VAR NAMES (postgres/github/notion/etc. all declare their secret
# here with config.env left empty) -- catalog_entry_to_paste_json must
# surface those names as blank env rows, not silently drop them.
entry = _minimal_catalog()["servers"][0]
entry["env_required"] = {"DATABASE_URI": ""}
result = c.catalog_entry_to_paste_json(entry)
assert result["widget"]["env"] == {"DATABASE_URI": ""}
def test_catalog_entry_to_paste_json_config_env_wins_over_env_required_default():
entry = _minimal_catalog()["servers"][0]
entry["config"]["env"] = {"GRAFANA_URL": "<GRAFANA_URL>"}
entry["env_required"] = {"GRAFANA_URL": "", "GRAFANA_SERVICE_ACCOUNT_TOKEN": ""}
result = c.catalog_entry_to_paste_json(entry)
assert result["widget"]["env"] == {
"GRAFANA_URL": "<GRAFANA_URL>",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "",
}
def test_catalog_entry_to_paste_json_real_postgres_entry_seeds_database_uri():
"""End-to-end regression against the actual shipped postgres entry,
which needs DATABASE_URI via env_required and has no config.env at
all -- this is exactly the shape that was silently dropping the env
field before catalog_entry_to_paste_json accounted for env_required."""
root = Path(__file__).resolve().parent.parent
raw = (root / "data" / "catalog.json").read_bytes()
data = c.load_catalog(raw)
entry = next(s for s in data["servers"] if s["id"] == "postgres")
result = c.catalog_entry_to_paste_json(entry)
assert result["postgres"]["env"] == {"DATABASE_URI": ""}
# And the focus-target helper now has something to point the user at.
assert c.first_unfilled_focus_target(result["postgres"]) == ("env", "DATABASE_URI")
def test_config_has_unfilled_placeholders_true_for_token():
cfg = {"command": "npx", "args": ["-y", "server", "<ALLOWED_DIR>"]}
assert c.config_has_unfilled_placeholders(cfg) is True
@@ -2374,709 +2410,307 @@ def test_config_has_unfilled_placeholders_checks_env_too():
# --------------------------------------------------------------------------- #
# #72 -- a server value that isn't a JSON object must not take the load down
# Browse-catalog dialog helpers (issue #10 phase 2)
# --------------------------------------------------------------------------- #
@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
# --------------------------------------------------------------------------- #
# --- catalog_category_group / CATALOG_CATEGORY_GROUPS --------------------- #
@pytest.mark.parametrize(
"setting,system_dark,expected",
"category,expected_group",
[
(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"),
("files", "Files & Dev"),
("dev", "Files & Dev"),
("code-hosting", "Files & Dev"),
("browser", "Files & Dev"),
("database", "Data"),
("data", "Data"),
("search", "Search & AI"),
("ai", "Search & AI"),
("cloud", "Cloud & Infra"),
("infra", "Cloud & Infra"),
("observability", "Cloud & Infra"),
("productivity", "Work"),
("communication", "Work"),
("crm", "Work"),
("finance", "Work"),
("design", "Work"),
("media", "Home & Personal"),
("smart-home", "Home & Personal"),
("personal", "Home & Personal"),
],
)
def test_resolve_theme_covers_every_setting_and_appearance(setting, system_dark, expected):
assert c.resolve_theme(setting, system_dark) == expected
def test_catalog_category_group_maps_every_taxonomy_value(category, expected_group):
assert c.catalog_category_group(category) == expected_group
@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_catalog_category_group_unknown_falls_back_to_other():
assert c.catalog_category_group("some-future-category-nobody-has-seen-yet") == "Other"
assert c.catalog_category_group("") == "Other"
assert c.catalog_category_group(None) == "Other"
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_catalog_category_group_is_case_insensitive():
assert c.catalog_category_group("Files") == "Files & Dev"
assert c.catalog_category_group("DATABASE") == "Data"
def test_palette_for_unknown_name_falls_back_to_dark():
assert c.palette_for("chartreuse") is c.DARK_PALETTE
def test_shipped_catalog_categories_all_have_a_known_group():
"""Regression: every category actually used in data/catalog.json must
collapse to one of the 7 chips, never silently drop an entry."""
root = Path(__file__).resolve().parent.parent
raw = (root / "data" / "catalog.json").read_bytes()
data = c.load_catalog(raw)
for entry in data["servers"]:
group = c.catalog_category_group(entry["category"])
assert group in c.CATALOG_CATEGORY_CHIPS
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")
# --- catalog_entry_matches_query / filter_catalog_entries ------------------ #
def _catalog_entries():
return [
{
"id": "filesystem",
"display": "Filesystem",
"description": "Read/write access to local directories you choose.",
"category": "files",
},
{
"id": "postgres",
"display": "Postgres MCP Pro",
"description": "Query and inspect a PostgreSQL database.",
"category": "database",
},
{
"id": "slack",
"display": "Slack",
"description": "Search messages and send messages from your assistant.",
"category": "communication",
},
]
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}"
def test_catalog_entry_matches_query_empty_matches_everything():
entries = _catalog_entries()
assert c.filter_catalog_entries(entries, "") == entries
assert c.filter_catalog_entries(entries, " ") == entries
@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
def test_catalog_entry_matches_query_matches_id():
result = c.filter_catalog_entries(_catalog_entries(), "postgres")
assert [e["id"] for e in result] == ["postgres"]
@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_catalog_entry_matches_query_matches_display_case_insensitive():
result = c.filter_catalog_entries(_catalog_entries(), "SLACK")
assert [e["id"] for e in result] == ["slack"]
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_catalog_entry_matches_query_matches_description():
result = c.filter_catalog_entries(_catalog_entries(), "PostgreSQL database")
assert [e["id"] for e in result] == ["postgres"]
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_catalog_entry_matches_query_matches_category():
result = c.filter_catalog_entries(_catalog_entries(), "database")
assert [e["id"] for e in result] == ["postgres"]
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_catalog_entry_matches_query_no_match_returns_empty():
assert c.filter_catalog_entries(_catalog_entries(), "kubernetes") == []
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"
def test_catalog_entries_in_group_all_returns_everything():
entries = _catalog_entries()
assert c.catalog_entries_in_group(entries, "All") == entries
assert c.catalog_entries_in_group(entries, "") == entries
assert c.catalog_entries_in_group(entries, None) == entries
# --------------------------------------------------------------------------- #
# #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_catalog_entries_in_group_filters_by_collapsed_category():
result = c.catalog_entries_in_group(_catalog_entries(), "Data")
assert [e["id"] for e in result] == ["postgres"]
result = c.catalog_entries_in_group(_catalog_entries(), "Work")
assert [e["id"] for e in result] == ["slack"]
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
# --- format_freshness_hint -------------------------------------------------- #
def test_format_freshness_hint_none_returns_empty_string():
assert c.format_freshness_hint(None) == ""
assert c.format_freshness_hint("") == ""
@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_format_freshness_hint_unparseable_returns_empty_string():
assert c.format_freshness_hint("not-a-date") == ""
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_format_freshness_hint_this_month():
assert (
c.format_freshness_hint("2026-07-01", today=c.date(2026, 7, 12))
== "Last updated this month"
)
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_format_freshness_hint_one_month_singular():
assert (
c.format_freshness_hint("2026-06-01", today=c.date(2026, 7, 12))
== "Last updated 1 month ago"
)
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_format_freshness_hint_months_ago():
# Exactly 14 full months elapsed, no day-of-month remainder to round off.
assert (
c.format_freshness_hint("2025-01-15", today=c.date(2026, 3, 15))
== "Last updated 14 months ago"
)
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"
def test_format_freshness_hint_rounds_down_partial_month():
# 2025-05-16 -> 2026-07-12 is 13 full months, not 14: the 14th month
# would only complete on 2026-07-16.
assert (
c.format_freshness_hint("2025-05-16", today=c.date(2026, 7, 12))
== "Last updated 13 months ago"
)
# --------------------------------------------------------------------------- #
# #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")]
def test_format_freshness_hint_years_ago():
assert (
c.format_freshness_hint("2024-01-01", today=c.date(2026, 7, 12))
== "Last updated 2 years ago"
)
@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_format_freshness_hint_23_months_stays_in_months_not_years():
# The switch to "N years ago" happens at 24 full months, not 12 -- the
# whole point of this hint is the granular "14 months ago" phrasing the
# design comment on #10 asked for, so 13-23 months must stay in months.
assert (
c.format_freshness_hint("2024-08-12", today=c.date(2026, 7, 12))
== "Last updated 23 months ago"
)
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_format_freshness_hint_future_date_returns_empty_string():
# A last_release "in the future" relative to `today` is nonsensical --
# show nothing rather than a misleading negative offset.
assert c.format_freshness_hint("2027-01-01", today=c.date(2026, 7, 12)) == ""
def test_server_env_refs_covers_all_five_documented_fields():
# --- first_unfilled_focus_target -------------------------------------------- #
def test_first_unfilled_focus_target_prefers_placeholder_arg():
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"),
"command": "npx",
"args": ["-y", "server", "<ALLOWED_DIR>"],
"env": {"API_KEY": ""},
}
assert c.first_unfilled_focus_target(data) == ("args", 2)
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_first_unfilled_focus_target_falls_back_to_first_blank_env():
data = {"command": "uvx", "args": ["mcp-grafana"], "env": {"GRAFANA_URL": ""}}
assert c.first_unfilled_focus_target(data) == ("env", "GRAFANA_URL")
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_first_unfilled_focus_target_none_when_fully_filled():
data = {"command": "npx", "args": ["-y", "server"], "env": {"API_KEY": "sk-real-value"}}
assert c.first_unfilled_focus_target(data) is None
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_first_unfilled_focus_target_none_for_config_with_no_env_or_args():
assert c.first_unfilled_focus_target({"command": "npx", "args": []}) is None
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"]
# --- load_bundled_catalog_entries ------------------------------------------- #
def test_load_bundled_catalog_entries_valid_signature(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_minimal_catalog(version=1), priv)
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
entries = c.load_bundled_catalog_entries(catalog_path, sig_path)
assert len(entries) == 1
assert entries[0]["id"] == "widget"
# --- 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_load_bundled_catalog_entries_tampered_payload_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_minimal_catalog(version=1), priv)
tampered = bytearray(raw)
tampered[-2] ^= 0xFF # flip a byte inside the trailing bytes, still valid-ish JSON shape
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(bytes(tampered))
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
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_load_bundled_catalog_entries_wrong_key_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
other_priv = Ed25519PrivateKey.generate()
other_pub = other_priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [other_pub])
raw, sig = _signed(_minimal_catalog(version=1), priv) # signed by the WRONG key
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
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_load_bundled_catalog_entries_missing_files_returns_empty_list(tmp_path):
assert c.load_bundled_catalog_entries(tmp_path / "nope.json", tmp_path / "nope.json.sig") == []
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_load_bundled_catalog_entries_missing_sig_returns_empty_list(tmp_path, monkeypatch):
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, _sig = _signed(_minimal_catalog(version=1), priv)
catalog_path = tmp_path / "catalog.json"
catalog_path.write_bytes(raw)
missing_sig_path = tmp_path / "catalog.json.sig" # never written
assert c.load_bundled_catalog_entries(catalog_path, missing_sig_path) == []
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
def test_load_bundled_catalog_entries_invalid_but_signed_returns_empty_list(tmp_path, monkeypatch):
"""A payload that verifies but fails validate_catalog() (disallowed
command) must still come back empty -- signing is necessary, not
sufficient."""
priv = Ed25519PrivateKey.generate()
pub = priv.public_key().public_bytes_raw()
monkeypatch.setattr(c, "CATALOG_PUBKEYS", [pub])
raw, sig = _signed(_catalog_with({"config": {"command": "bash", "args": []}}), priv)
catalog_path = tmp_path / "catalog.json"
sig_path = tmp_path / "catalog.json.sig"
catalog_path.write_bytes(raw)
sig_path.write_bytes(sig)
assert c.load_bundled_catalog_entries(catalog_path, sig_path) == []
# --- 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
def test_load_bundled_catalog_entries_real_shipped_catalog():
"""End-to-end regression against the actual bundled data/catalog.json +
.sig, using the real CATALOG_PUBKEYS (no monkeypatch) -- this is what
the Browse dialog actually calls on startup."""
root = Path(__file__).resolve().parent.parent
entries = c.load_bundled_catalog_entries(
root / "data" / "catalog.json", root / "data" / "catalog.json.sig"
)
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
# --------------------------------------------------------------------------- #
# Move to environment variable (issue #83)
# --------------------------------------------------------------------------- #
@pytest.mark.parametrize(
"raw,expected",
[
("API_KEY", "API_KEY"),
("api-key", "API_KEY"),
("x.y z", "X_Y_Z"),
("2fa", "_2FA"),
("", "VAR"),
("***", "VAR"),
("clé", "CL_"), # non-ASCII becomes _
],
)
def test_sanitize_env_var_name(raw, expected):
assert c.sanitize_env_var_name(raw) == expected
def test_shell_export_lines_quote_safely():
lines = c.shell_export_lines("TOKEN", "ab'cd")
assert lines["posix"] == "export TOKEN='ab'\\''cd'"
assert lines["windows"] == 'setx TOKEN "ab\'cd"'
def test_can_move_gate_requires_secret_and_expanding_client():
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)
# real secret on an expanding client -> offer
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", code) is True
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", None) is True
# non-secret key -> no
assert c.can_move_value_to_env_ref("REGION", "us-east-1", code) is False
# already a reference -> no
assert c.can_move_value_to_env_ref("API_KEY", "${API_KEY}", code) is False
# non-expanding client (Claude Desktop) -> refuse even a real secret
assert c.can_move_value_to_env_ref("API_KEY", "ghp_abc", desktop) is False
def test_move_env_value_replaces_with_reference_and_returns_secret():
data = {"command": "x", "env": {"API_KEY": "ghp_secret", "REGION": "us"}}
conv = c.move_value_to_env_ref(data, field="env", key="API_KEY")
assert conv is not None
assert conv.var_name == "API_KEY"
assert conv.reference == "${API_KEY}"
assert conv.secret == "ghp_secret"
assert conv.data["env"]["API_KEY"] == "${API_KEY}"
# non-secret row untouched
assert conv.data["env"]["REGION"] == "us"
# input never mutated
assert data["env"]["API_KEY"] == "ghp_secret"
def test_move_derives_and_sanitises_var_name_from_key():
data = {"headers": {"x-api-key": "sekret"}}
conv = c.move_value_to_env_ref(data, field="headers", key="x-api-key")
assert conv.var_name == "X_API_KEY"
assert conv.data["headers"]["x-api-key"] == "${X_API_KEY}"
def test_move_honours_explicit_var_name():
data = {"env": {"tok": "sekret"}}
conv = c.move_value_to_env_ref(data, field="env", key="tok", var_name="GITHUB_TOKEN")
assert conv.reference == "${GITHUB_TOKEN}"
assert conv.data["env"]["tok"] == "${GITHUB_TOKEN}"
def test_move_args_by_index():
data = {"command": "x", "args": ["--token", "ghp_secret"]}
conv = c.move_value_to_env_ref(data, field="args", index=1, var_name="GH_TOKEN")
assert conv.secret == "ghp_secret"
assert conv.data["args"] == ["--token", "${GH_TOKEN}"]
def test_move_returns_none_on_missing_or_nonstring_or_already_ref():
data = {"env": {"API_KEY": "${API_KEY}", "N": 5}}
assert c.move_value_to_env_ref(data, field="env", key="ABSENT") is None
assert c.move_value_to_env_ref(data, field="env", key="N") is None # not a string
assert c.move_value_to_env_ref(data, field="env", key="API_KEY") is None # already a ref
assert c.move_value_to_env_ref({}, field="bogus") is None
assert c.move_value_to_env_ref({"args": ["a"]}, field="args", index=9) is None
def test_is_env_var_set():
assert c.is_env_var_set("FOO", {"FOO": "x"}) is True
assert c.is_env_var_set("FOO", {"FOO": ""}) is False
assert c.is_env_var_set("FOO", {}) is False
assert len(entries) == 19
assert {e["id"] for e in entries} >= {"filesystem", "github", "slack", "postgres"}