3 Commits
Author SHA1 Message Date
the_ogandClaude Opus 4.8 8fdbe90b37 feat: "Move to environment variable" — convert a plaintext secret to ${VAR} (#83)
CI / Lint (ruff) (pull_request) Successful in 14s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 21s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 20s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 21s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 23s
CI / Catalog signature (pull_request) Successful in 17s
Follow-up to #76/#82: BCC warns when a config holds a raw credential and
points at ${VAR}, but gave no way to make the change. This adds the
one-click conversion, right-click a secret row in the env or headers table.

The value is about to leave the file, so the action's real job is handing
the secret back before it does:

- Core (pure, tested): sanitize_env_var_name (key -> legal upper-case shell
  name; 'api-key' -> API_KEY, '2fa' -> _2FA, non-ASCII/empty handled),
  shell_export_lines (the exact export/setx line, POSIX single-quoted
  safely), move_value_to_env_ref (data in -> new data out, replaces one
  env/header/args value with ${VAR}, returns the removed secret; never
  mutates the input; None if the target is missing, non-string, or already a
  reference), can_move_value_to_env_ref (offer only a real stored secret, not
  already a ref, AND only on a client that expands references -- offering it
  on Claude Desktop would author a config that reaches the server as literal
  ${VAR}, the exact failure #76 exists to prevent), and is_env_var_set (skip
  the ceremony when the variable already looks set).

- GUI: KeyValueTable gains a context menu gated on can_move_value_to_env_ref
  (so it never appears on a non-secret row or a Claude Desktop profile).
  MoveToEnvDialog lets the user name the variable (defaulting to the
  sanitised key), shows the platform-appropriate shell line live, notes when
  the variable already looks set, and on accept copies the secret to the
  clipboard before the cell is replaced with the reference. Wired through
  ServerEditor.set_profile_provider so the tables know which client is loaded.

Scope note: env and headers rows for now. The core already handles args by
index; wiring the args editor (a free-text widget, not a table) is a small
follow-up, deliberately not bundled here.

Tests: +15 core (name sanitisation incl. non-ASCII/leading-digit/empty,
POSIX quote safety, the gate across secret/non-secret/already-ref/
non-expanding-client, env+headers+args rewrite, input-not-mutated,
missing/non-string/already-ref -> None, is_env_var_set). 478 passed, ruff
clean. GUI is untestable in CI (no PySide6); the decision logic all lives in
bcc_core and is tested there.

Closes #83

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EKwBecy6N83jnqQmw8ezwE
2026-08-04 03:43:21 +00:00
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
3 changed files with 142 additions and 10 deletions
+5
View File
@@ -2180,6 +2180,11 @@ class MainWindow(QMainWindow):
for p in self.profiles:
tag = "" if p.config_exists else " (no config yet)"
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)
if self.profiles:
self.profile_combo.setCurrentIndex(0)
+69 -10
View File
@@ -648,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]:
"""
Find project-scope `.mcp.json` configs known to Claude Code.
`~/.claude.json` keeps a `projects` map keyed by absolute project
directory path (that's what the CLI writes as it's used in each repo).
Any project whose directory has a `.mcp.json` file next to it -- a
standalone file with a top-level `mcpServers` object, same shape BCC
already edits -- is surfaced here as its own profile so it can be opened
via 'Add config...' without hunting for the path by hand.
Any project whose directory has a `.mcp.json` file that parses to a JSON
object -- the same shape BCC already edits -- is surfaced here as its own
profile so it can be opened via 'Add config...' without hunting for the
path by hand. 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
`projects` value that isn't a dict, just yields an empty list rather than
@@ -670,14 +721,22 @@ def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profil
projects = cfg.get("projects")
if not isinstance(projects, dict):
return []
out: list[Profile] = []
valid_dirs: list[Path] = []
for key in sorted(k for k in projects if isinstance(k, str)):
mcp_path = Path(key) / ".mcp.json"
if mcp_path.is_file():
out.append(
Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True)
)
return out
if not mcp_path.is_file():
continue
try:
data = load_config(mcp_path) # strict; raises on bad JSON / non-object
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]:
+68
View File
@@ -2993,6 +2993,74 @@ def test_server_sections_and_change_summary_follow_a_custom_key(tmp_path):
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
# --------------------------------------------------------------------------- #
# Move to environment variable (issue #83)
# --------------------------------------------------------------------------- #