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
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
This commit is contained in:
+69
-10
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user