From 520b1b2ffdb2ca80bccd337ed66679c7b56525fc Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 13:53:38 -0400 Subject: [PATCH 1/3] feat: discover Claude Code project .mcp.json configs as profiles (#53) Closes #53 Co-Authored-By: Claude Fable 5 --- bcc_core.py | 45 ++++++++++++++++++++++++++++++++++- tests/test_core.py | 58 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/bcc_core.py b/bcc_core.py index be511cf..f6fb203 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -282,10 +282,43 @@ def msix_warning_text( ) +def discover_project_configs(claude_json_path: str | os.PathLike) -> list[Profile]: + """ + Find project-scope `.mcp.json` configs known to Claude Code. + + `~/.claude.json` keeps a `projects` map keyed by absolute project + directory path (that's what the CLI writes as it's used in each repo). + Any project whose directory has a `.mcp.json` file next to it -- a + standalone file with a top-level `mcpServers` object, same shape BCC + already edits -- is surfaced here as its own profile so it can be opened + via 'Add config...' without hunting for the path by hand. + + Fails quiet: a missing/unreadable/malformed `claude_json_path`, or a + `projects` value that isn't a dict, just yields an empty list rather than + raising -- this is best-effort discovery, not a required config load. + """ + try: + cfg = load_config(claude_json_path) + except Exception: + return [] + projects = cfg.get("projects") + if not isinstance(projects, dict): + return [] + out: list[Profile] = [] + for key in sorted(k for k in projects if isinstance(k, str)): + mcp_path = Path(key) / ".mcp.json" + if mcp_path.is_file(): + out.append( + Profile(label=f"Project: {Path(key).name}", path=mcp_path, config_exists=True) + ) + return out + + def discover_profiles() -> list[Profile]: """ Find every `Claude*` data directory in the platform's app-support base - (Claude Desktop installs), then also check for a Claude Code global config. + (Claude Desktop installs), then also check for a Claude Code global config + and any project-scope `.mcp.json` configs it knows about. Claude Desktop: scans the platform app-support folder for any `Claude*` directory (catches `Claude`, `Claude-Work`, etc.). @@ -298,6 +331,10 @@ def discover_profiles() -> list[Profile]: `claude mcp add` writes; project scope is a per-repo .mcp.json, which can be opened via 'Add config…'). NOT ~/.claude/settings.json — that file is for permissions/hooks and rejects an mcpServers key with a schema error. + Project scope: ~/.claude.json also tracks a `projects` map, one entry per + directory Claude Code has been run in; any of those with a `.mcp.json` + file are surfaced as their own profiles too (see + `discover_project_configs`). """ base = app_support_base() out: list[Profile] = [] @@ -319,6 +356,12 @@ def discover_profiles() -> list[Profile]: cc_cfg = home / ".claude.json" out.append(Profile(label="Claude Code", path=cc_cfg, config_exists=cc_cfg.is_file())) + existing_paths = {str(p.path) for p in out} + for proj in discover_project_configs(cc_cfg): + if str(proj.path) not in existing_paths: + existing_paths.add(str(proj.path)) + out.append(proj) + # Legacy: earlier BCC versions (and hand-edits) may have parked servers in # ~/.claude/settings.json, where Claude Code ignores them. Surface that # file only when it actually contains an mcpServers block, so the user can diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..ccd4d93 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -67,6 +67,64 @@ def test_legacy_settings_json_surfaced_only_with_servers(fake_home): assert any("legacy" in p.label for p in c.discover_profiles()) +def test_project_with_mcp_json_is_discovered(tmp_path): + proj = tmp_path / "my-project" + proj.mkdir() + (proj / ".mcp.json").write_text('{"mcpServers": {"x": {"command": "npx"}}}') + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"projects": {str(proj): {}}})) + + profs = c.discover_project_configs(claude_json) + assert len(profs) == 1 + assert profs[0].label == f"Project: {proj.name}" + assert profs[0].path == proj / ".mcp.json" + assert profs[0].config_exists + + +def test_project_without_mcp_json_not_listed(tmp_path): + proj = tmp_path / "no-mcp-project" + proj.mkdir() + claude_json = tmp_path / ".claude.json" + claude_json.write_text(json.dumps({"projects": {str(proj): {}}})) + + assert c.discover_project_configs(claude_json) == [] + + +def test_projects_missing_or_not_dict_yields_no_profiles(tmp_path): + claude_json = tmp_path / ".claude.json" + + claude_json.write_text(json.dumps({})) + assert c.discover_project_configs(claude_json) == [] + + claude_json.write_text(json.dumps({"projects": ["not", "a", "dict"]})) + assert c.discover_project_configs(claude_json) == [] + + +def test_malformed_claude_json_fails_quiet(fake_home): + (fake_home / ".claude.json").write_text("{not valid json") + assert c.discover_project_configs(fake_home / ".claude.json") == [] + # discover_profiles as a whole must still work and just skip project profiles + profs = c.discover_profiles() + assert not any(p.label.startswith("Project:") for p in profs) + + +def test_project_configs_deduped_against_existing_profiles(fake_home): + # Point a project directly at the Claude Code profile's own .mcp.json-shaped + # path to prove discover_profiles() won't duplicate an already-listed path. + proj = fake_home / "dup-project" + proj.mkdir() + mcp_path = proj / ".mcp.json" + mcp_path.write_text('{"mcpServers": {"x": {"command": "npx"}}}') + (fake_home / ".claude.json").write_text(json.dumps({"projects": {str(proj): {}}})) + + direct = c.discover_project_configs(fake_home / ".claude.json") + assert len(direct) == 1 + + profs = c.discover_profiles() + project_profiles = [p for p in profs if str(p.path) == str(mcp_path)] + assert len(project_profiles) == 1 + + # --------------------------------------------------------------------------- # # 2. Write pipeline: preserves other keys + order, only touches mcpServers # --------------------------------------------------------------------------- # From 82ff149373b4f55cb8b83d94c2f171a086b5c592 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 13:55:22 -0400 Subject: [PATCH 2/3] feat: structural schema lint for server definitions (#54) Closes #54 Co-Authored-By: Claude Fable 5 --- bcc.py | 9 ++++-- bcc_core.py | 57 +++++++++++++++++++++++++++++++++ tests/test_core.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/bcc.py b/bcc.py index caebee8..d8c15e1 100644 --- a/bcc.py +++ b/bcc.py @@ -2309,8 +2309,13 @@ class MainWindow(QMainWindow): self.validation_lbl.setStyleSheet(f"color: {WARN};") self.save_btn.setEnabled(False) return False - self.validation_lbl.setText("✓ valid") - self.validation_lbl.setStyleSheet(f"color: {GOOD};") + lint_warnings = core.lint_servers(self.servers) + if lint_warnings: + self.validation_lbl.setText(f"⚠ {lint_warnings[0]}") + self.validation_lbl.setStyleSheet(f"color: {WARN};") + else: + self.validation_lbl.setText("✓ valid") + self.validation_lbl.setStyleSheet(f"color: {GOOD};") self.save_btn.setEnabled(self.dirty) return True diff --git a/bcc_core.py b/bcc_core.py index be511cf..63767a3 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -1213,6 +1213,63 @@ def validate_servers(servers: list[ServerEntry]) -> list[str]: return problems +def lint_server(name: str, data: dict) -> list[str]: + """Return non-blocking structural warnings for a single server definition. + + Unlike validate_servers, nothing here blocks Save -- these are advisory + notes about shapes that will round-trip through JSON fine but are + probably not what the user intended (args given as a plain string + instead of a list, an env value that isn't a string, an unrecognized + `type`, unknown top-level fields, etc.). + """ + nm = name.strip() or "(unnamed)" + warnings: list[str] = [] + + if "command" in data and not isinstance(data["command"], str): + warnings.append(f"'{nm}': 'command' should be a string") + + if "args" in data: + args = data["args"] + if not isinstance(args, list): + warnings.append(f"'{nm}': 'args' should be a list (one argument per item)") + elif any(not isinstance(a, str) for a in args): + warnings.append( + f"'{nm}': 'args' contains non-string values " + "(they will be saved as-is; Claude expects strings)" + ) + + for field in ("env", "headers"): + if field not in data: + continue + val = data[field] + if not isinstance(val, dict): + warnings.append(f"'{nm}': '{field}' should be an object of string key/value pairs") + elif any(not isinstance(v, str) for v in val.values()): + warnings.append( + f"'{nm}': '{field}' contains non-string values " + "(they will be saved as-is; Claude expects strings)" + ) + + if "type" in data: + t = data["type"] + if t not in ("http", "sse", "stdio"): + warnings.append(f"'{nm}': 'type' should be one of http, sse, stdio (found {t!r})") + + extra = sorted(k for k in data if k not in KNOWN_FIELDS) + if extra: + warnings.append(f"'{nm}': extra fields preserved as-is: {', '.join(extra)}") + + return warnings + + +def lint_servers(servers: list[ServerEntry]) -> list[str]: + """Concatenate lint_server warnings across every entry, in order.""" + out: list[str] = [] + for s in servers: + out.extend(lint_server(s.name, s.data)) + return out + + # --------------------------------------------------------------------------- # # Search / filter # --------------------------------------------------------------------------- # diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..76d8402 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -181,6 +181,85 @@ def test_valid_set_passes_clean(): assert c.validate_servers([c.ServerEntry("good", {"command": "node"}, True)]) == [] +# --------------------------------------------------------------------------- # +# 4b. Lint: non-blocking structural warnings +# --------------------------------------------------------------------------- # +def test_lint_flags_non_string_command(): + warnings = c.lint_server("x", {"command": 5}) + assert any("'command' should be a string" in w for w in warnings) + + +def test_lint_flags_args_not_a_list(): + warnings = c.lint_server("x", {"command": "node", "args": "-y foo"}) + assert any("'args' should be a list" in w for w in warnings) + + +def test_lint_flags_args_with_non_string_items(): + warnings = c.lint_server("x", {"command": "node", "args": ["-y", 5]}) + assert any("'args' contains non-string values" in w for w in warnings) + + +def test_lint_flags_env_not_a_dict(): + warnings = c.lint_server("x", {"command": "node", "env": ["FOO=bar"]}) + assert any("'env' should be an object" in w for w in warnings) + + +def test_lint_flags_env_with_non_string_values(): + warnings = c.lint_server("x", {"command": "node", "env": {"FOO": 5}}) + assert any("'env' contains non-string values" in w for w in warnings) + + +def test_lint_flags_headers_not_a_dict(): + warnings = c.lint_server("x", {"url": "https://x", "headers": ["Authorization: x"]}) + assert any("'headers' should be an object" in w for w in warnings) + + +def test_lint_flags_headers_with_non_string_values(): + warnings = c.lint_server("x", {"url": "https://x", "headers": {"Authorization": 5}}) + assert any("'headers' contains non-string values" in w for w in warnings) + + +def test_lint_flags_bad_type_value(): + warnings = c.lint_server("x", {"url": "https://x", "type": "websocket"}) + assert any("'type'" in w and "websocket" in w for w in warnings) + + +def test_lint_flags_extra_unknown_fields(): + warnings = c.lint_server("x", {"command": "node", "cwd": "/tmp", "timeout": 30}) + matches = [w for w in warnings if "extra fields preserved as-is" in w] + assert len(matches) == 1 + assert "cwd" in matches[0] + assert "timeout" in matches[0] + + +def test_lint_clean_server_yields_no_warnings(): + assert c.lint_server("good", {"command": "node", "args": ["a.js"]}) == [] + assert ( + c.lint_server( + "remote", {"url": "https://x", "type": "http", "headers": {"Authorization": "x"}} + ) + == [] + ) + + +def test_lint_servers_aggregates_across_entries(): + entries = [ + c.ServerEntry("a", {"command": 5}, True), + c.ServerEntry("b", {"url": "https://x", "type": "bogus"}, True), + ] + warnings = c.lint_servers(entries) + assert any("'a'" in w and "'command' should be a string" in w for w in warnings) + assert any("'b'" in w and "'type'" in w for w in warnings) + + +def test_lint_warning_is_not_a_blocking_problem(): + # args given as a single string isn't caught by validate_servers (a + # command is still present), but it's a lint warning. + entry = c.ServerEntry("x", {"command": "node", "args": "-y foo"}, True) + assert c.validate_servers([entry]) == [] + assert c.lint_servers([entry]) != [] + + # --------------------------------------------------------------------------- # # 5. Secrets: detection + redaction # --------------------------------------------------------------------------- # From ac2e73e9d75da40363ad1f2e341755c56e7c6684 Mon Sep 17 00:00:00 2001 From: Cowork Supervisor Date: Sun, 12 Jul 2026 13:55:36 -0400 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20named=20server=20sets=20=E2=80=94?= =?UTF-8?q?=20save/apply=20the=20Active/Disabled=20split=20(#52)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets live in the config under _bccServerSets (bcc-owned, ignored by Claude, travels with the file). Apply enables exactly the set's members and parks the rest; vanished members are reported, not fatal. GUI row: set combo + Apply + Save set… + delete. Closes #52 Co-Authored-By: Claude Fable 5 --- bcc.py | 93 ++++++++++++++++++++++++++++++++++++++++++++++ bcc_core.py | 70 ++++++++++++++++++++++++++++++++++ tests/test_core.py | 62 +++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+) diff --git a/bcc.py b/bcc.py index caebee8..5110ff6 100644 --- a/bcc.py +++ b/bcc.py @@ -38,6 +38,7 @@ from PySide6.QtWidgets import ( QGridLayout, QHBoxLayout, QHeaderView, + QInputDialog, QLabel, QLineEdit, QListWidget, @@ -1692,6 +1693,30 @@ class MainWindow(QMainWindow): self.search_box.textChanged.connect(self._on_search_changed) v.addWidget(self.search_box) + # Named server sets (issue #52): apply a saved Active/Disabled split + # in one click. Sets live in the config file under _bccServerSets. + sets_row = QHBoxLayout() + sets_lbl = QLabel("Set") + sets_lbl.setObjectName("muted") + sets_row.addWidget(sets_lbl) + self.sets_combo = QComboBox() + self.sets_combo.setMinimumWidth(120) + sets_row.addWidget(self.sets_combo, 1) + self.apply_set_btn = QPushButton("Apply") + self.apply_set_btn.setToolTip("Enable exactly this set's servers; disable the rest") + self.apply_set_btn.clicked.connect(self._apply_selected_set) + sets_row.addWidget(self.apply_set_btn) + self.save_set_btn = QPushButton("Save set…") + self.save_set_btn.setToolTip("Save the current Active/Disabled split as a named set") + self.save_set_btn.clicked.connect(self._save_set) + sets_row.addWidget(self.save_set_btn) + self.del_set_btn = QPushButton("−") + self.del_set_btn.setToolTip("Delete the selected set") + self.del_set_btn.setMaximumWidth(32) + self.del_set_btn.clicked.connect(self._delete_set) + sets_row.addWidget(self.del_set_btn) + v.addLayout(sets_row) + # Active and Disabled sections live in a vertical splitter so the user # can drag the divider instead of being stuck with a fixed-height # disabled list. @@ -1892,6 +1917,7 @@ class MainWindow(QMainWindow): self._undo_stack.clear() self.undo_btn.setEnabled(False) self._health.clear() # health results are per-profile; a fresh load invalidates them + self._refresh_sets_combo() # sets are per-config; repopulate from the loaded file self._refresh_tables(select_index=0 if self.servers else -1) self._update_status(saved=False) if repaired: @@ -1991,6 +2017,73 @@ class MainWindow(QMainWindow): cur = self._current_index() self._refresh_tables(select_index=cur if cur >= 0 else None) + # --- named server sets (issue #52) ------------------------------------ # + def _refresh_sets_combo(self, select: str | None = None): + sets = core.list_server_sets(self.full_config) + self.sets_combo.blockSignals(True) + self.sets_combo.clear() + for name in sorted(sets): + self.sets_combo.addItem(name) + if select is not None: + idx = self.sets_combo.findText(select) + if idx >= 0: + self.sets_combo.setCurrentIndex(idx) + self.sets_combo.blockSignals(False) + has_sets = bool(sets) + self.apply_set_btn.setEnabled(has_sets) + self.del_set_btn.setEnabled(has_sets) + + def _apply_selected_set(self): + name = self.sets_combo.currentText() + sets = core.list_server_sets(self.full_config) + if name not in sets: + return + self._push_undo() + missing = core.apply_server_set(self.servers, sets[name]) + self._refresh_tables(select_index=self._current_index() if self.servers else None) + self._mark_dirty() + on = sum(1 for s in self.servers if s.enabled) + msg = f"Applied set “{name}” · {on} enabled. Review and Save." + if missing: + msg += f" ⚠ no longer in this config: {', '.join(missing)}" + self.status.setText(msg) + + def _save_set(self): + name, ok = QInputDialog.getText( + self, + "Save server set", + "Set name (saves which servers are currently Active):", + text=self.sets_combo.currentText(), + ) + name = name.strip() + if not ok or not name: + return + if name in core.list_server_sets(self.full_config) and ( + QMessageBox.question(self, "Set exists", f"Replace set “{name}”?") + != QMessageBox.StandardButton.Yes + ): + return + members = core.save_server_set(self.full_config, name, self.servers) + self._refresh_sets_combo(select=name) + self._mark_dirty() # the set is written on the next Save + self.status.setText( + f"Set “{name}” saved ({len(members)} server(s)). Press Save to write it." + ) + + def _delete_set(self): + name = self.sets_combo.currentText() + if not name: + return + if ( + QMessageBox.question(self, "Delete set", f"Delete set “{name}”?") + != QMessageBox.StandardButton.Yes + ): + return + if core.delete_server_set(self.full_config, name): + self._refresh_sets_combo() + self._mark_dirty() + self.status.setText(f"Set “{name}” deleted. Press Save to write the change.") + # --- test all (spawn-test every enabled local server) ---------------- # def _test_all_servers(self): targets = [s for s in self.servers if s.enabled and s.kind == "stdio"] diff --git a/bcc_core.py b/bcc_core.py index be511cf..a5298d0 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -39,6 +39,12 @@ CONFIG_FILENAME = "claude_desktop_config.json" # we can toggle it back on without losing the definition. DISABLED_KEY = "_disabledMcpServers" +# Named server sets: {set_name: [enabled server names]}. Same pattern as +# DISABLED_KEY — a bcc-owned key Claude ignores, stored in the config file so +# sets travel with it. Applying a set enables exactly the listed servers and +# parks the rest under DISABLED_KEY. +SETS_KEY = "_bccServerSets" + BACKUP_DIRNAME = ".bcc_backups" MAX_BACKUPS = 15 @@ -409,6 +415,70 @@ def extract_servers(cfg: dict) -> list[ServerEntry]: return out +# --------------------------------------------------------------------------- # +# Named server sets (issue #52) +# --------------------------------------------------------------------------- # +def list_server_sets(cfg: dict) -> dict[str, list[str]]: + """ + Return {set_name: [enabled server names]} from cfg's SETS_KEY. + + Fail-soft: entries whose value isn't a list of strings (hand-edited or + corrupted) are skipped rather than raising, so one bad set never hides + the rest. + """ + raw = cfg.get(SETS_KEY) + if not isinstance(raw, dict): + return {} + out: dict[str, list[str]] = {} + for name, members in raw.items(): + if isinstance(members, list) and all(isinstance(m, str) for m in members): + out[str(name)] = list(members) + return out + + +def save_server_set(cfg: dict, name: str, servers: list[ServerEntry]) -> list[str]: + """ + Snapshot the current enabled-server names into cfg under SETS_KEY as + `name` (overwriting an existing set of that name). Returns the saved + member list. The caller decides when cfg reaches disk (normal Save flow). + """ + members = [s.name for s in servers if s.enabled] + sets = cfg.get(SETS_KEY) + if not isinstance(sets, dict): + sets = {} + cfg[SETS_KEY] = sets + sets[name] = members + return members + + +def delete_server_set(cfg: dict, name: str) -> bool: + """Remove set `name` from cfg. Drops SETS_KEY entirely when the last set + goes, so untouched configs don't grow an empty bcc key. Returns True if + something was deleted.""" + sets = cfg.get(SETS_KEY) + if not isinstance(sets, dict) or name not in sets: + return False + del sets[name] + if not sets: + cfg.pop(SETS_KEY, None) + return True + + +def apply_server_set(servers: list[ServerEntry], enabled_names: list[str]) -> list[str]: + """ + Enable exactly the servers named in `enabled_names`; disable every other + entry (in place). Returns the set members that no longer exist in + `servers` — the caller surfaces those as a warning, and the rest of the + set still applies. + """ + wanted = set(enabled_names) + present: set[str] = set() + for s in servers: + s.enabled = s.name in wanted + present.add(s.name) + return sorted(wanted - present) + + def resolve_name_collision(name: str, existing: set[str]) -> str: """ Return a name guaranteed not to collide with `existing`. diff --git a/tests/test_core.py b/tests/test_core.py index 67f17fe..5afaf2e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -769,6 +769,68 @@ def test_args_secret_warning_empty(): assert c.args_secret_warning({"args": []}) is None +# --------------------------------------------------------------------------- # +# Named server sets (issue #52) +# --------------------------------------------------------------------------- # +def _three_servers(): + return [ + c.ServerEntry("alpha", {"command": "npx"}, True), + c.ServerEntry("beta", {"command": "uvx"}, True), + c.ServerEntry("gamma", {"url": "https://x.example/mcp"}, False), + ] + + +def test_save_and_list_server_sets_roundtrip(): + cfg: dict = {} + members = c.save_server_set(cfg, "webdev", _three_servers()) + assert members == ["alpha", "beta"] # only the enabled ones + assert c.list_server_sets(cfg) == {"webdev": ["alpha", "beta"]} + + +def test_apply_server_set_enables_exactly_the_members(): + servers = _three_servers() + missing = c.apply_server_set(servers, ["gamma"]) + assert missing == [] + assert [s.enabled for s in servers] == [False, False, True] + + +def test_apply_server_set_reports_missing_members(): + servers = _three_servers() + missing = c.apply_server_set(servers, ["alpha", "vanished", "gone"]) + assert missing == ["gone", "vanished"] + assert [s.enabled for s in servers] == [True, False, False] # rest still applied + + +def test_delete_server_set_drops_empty_key(): + cfg: dict = {} + c.save_server_set(cfg, "only", _three_servers()) + assert c.delete_server_set(cfg, "only") is True + assert c.SETS_KEY not in cfg # no empty bcc key left behind + assert c.delete_server_set(cfg, "only") is False + + +def test_server_sets_survive_write_and_reload(tmp_path): + cfgpath = tmp_path / "cfg.json" + cfg = {"mcpServers": {"alpha": {"command": "npx"}}} + c.save_server_set(cfg, "webdev", [c.ServerEntry("alpha", {"command": "npx"}, True)]) + c.write_config(cfgpath, cfg) + reloaded = c.load_config(cfgpath) + assert c.list_server_sets(reloaded) == {"webdev": ["alpha"]} + + +def test_apply_servers_preserves_sets_key(): + cfg: dict = {"mcpServers": {}} + c.save_server_set(cfg, "s", [c.ServerEntry("alpha", {"command": "npx"}, True)]) + c.apply_servers(cfg, _three_servers()) + assert c.SETS_KEY in cfg # the server writer never touches sets + + +def test_list_server_sets_skips_malformed_entries(): + cfg = {c.SETS_KEY: {"good": ["a"], "bad-not-list": "a", "bad-items": ["a", 3]}} + assert c.list_server_sets(cfg) == {"good": ["a"]} + assert c.list_server_sets({c.SETS_KEY: "junk"}) == {} + + # --------------------------------------------------------------------------- # # resolve_name_collision (paste/import duplicate-name handling — issue #8) # --------------------------------------------------------------------------- #