From 77f469c1ddc87bb3f84b3188d9c6da183d965492 Mon Sep 17 00:00:00 2001 From: AJ Date: Thu, 2 Jul 2026 00:10:27 -0400 Subject: [PATCH] feat: offer guided repair when loading a broken config file If a config file on disk fails strict JSON parsing, BCC now runs it through the same repair pipeline as pasted snippets and shows a dialog listing the parse error, each fix it would apply, and a preview of the resulting file. The user chooses: Repair & load (marks the profile dirty; the file is only rewritten on Save, after the broken original is backed up) or Cancel. Unsalvageable files keep the old error path. repair_config_file() in bcc_core never writes to disk itself. --- bcc.py | 80 ++++++++++++++++++++++++++++++++++++--- bcc_core.py | 27 +++++++++++++ tests/test_json_repair.py | 31 +++++++++++++++ 3 files changed, 132 insertions(+), 6 deletions(-) diff --git a/bcc.py b/bcc.py index 9433c61..20d46df 100644 --- a/bcc.py +++ b/bcc.py @@ -632,6 +632,56 @@ class ArgsEdit(QPlainTextEdit): n += 1 +# --------------------------------------------------------------------------- # +# Config-repair dialog: shown when a config file on disk fails strict JSON +# parsing but the repair pipeline can salvage it. Lists what's wrong, what +# would change, and lets the user decide. Nothing touches disk until Save. +# --------------------------------------------------------------------------- # +class RepairDialog(QDialog): + def __init__(self, parent, path, error: str, notes: list[str], preview: str): + super().__init__(parent) + self.setWindowTitle("Config file needs repair") + self.resize(620, 520) + v = QVBoxLayout(self) + v.setSpacing(10) + + intro = QLabel(f"{path}
isn't valid JSON: {error}") + intro.setWordWrap(True) + v.addWidget(intro) + + fixes_hdr = QLabel("BCC can repair it automatically. Fixes to apply:") + v.addWidget(fixes_hdr) + fixes = QLabel("\n".join(f" • {n}" for n in notes) or " • reformat as valid JSON") + fixes.setObjectName("muted") + fixes.setWordWrap(True) + v.addWidget(fixes) + + prev_hdr = QLabel("The file would become:") + v.addWidget(prev_hdr) + box = QPlainTextEdit() + box.setObjectName("diag") + box.setReadOnly(True) + box.setPlainText(preview) + v.addWidget(box, 1) + + note = QLabel( + "Nothing is written yet — choosing Repair loads the fixed config into the " + "editor. The file is only rewritten when you press Save, and the broken " + "original is backed up first." + ) + note.setObjectName("muted") + note.setWordWrap(True) + v.addWidget(note) + + btns = QDialogButtonBox() + ok = btns.addButton("Repair && load", QDialogButtonBox.ButtonRole.AcceptRole) + ok.setObjectName("primary") + btns.addButton(QDialogButtonBox.StandardButton.Cancel) + btns.accepted.connect(self.accept) + btns.rejected.connect(self.reject) + v.addWidget(btns) + + # --------------------------------------------------------------------------- # # Paste-JSON dialog # --------------------------------------------------------------------------- # @@ -972,15 +1022,27 @@ class MainWindow(QMainWindow): return if confirm and self.dirty and not self._confirm_discard(): return + repaired = False try: self.full_config = core.load_config(profile.path) except Exception as e: - QMessageBox.critical( - self, - "Could not read config", - f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install.", - ) - return + # Strict parse failed — see if the repair pipeline can salvage it, + # and let the user decide with full visibility of the changes. + try: + cfg, notes, preview = core.repair_config_file(profile.path) + except Exception: + QMessageBox.critical( + self, + "Could not read config", + f"{profile.path}\n\n{e}\n\nThe file couldn't be repaired automatically. " + f"Fix it by hand or pick another install.", + ) + return + dlg = RepairDialog(self, profile.path, str(e), notes, preview) + if not dlg.exec(): + return + self.full_config = cfg + repaired = True self.current_profile = profile self.servers = core.extract_servers(self.full_config) self.dirty = False @@ -988,6 +1050,12 @@ class MainWindow(QMainWindow): self.undo_btn.setEnabled(False) self._refresh_tables(select_index=0 if self.servers else -1) self._update_status(saved=False) + if repaired: + self._mark_dirty() + self.status.setText( + f"{profile.path} · repaired config loaded — press Save to write the " + f"fix (the original file will be backed up)" + ) # --- table rendering ------------------------------------------------- # def _add_row(self, table, master_idx, s): diff --git a/bcc_core.py b/bcc_core.py index 397fc6f..f471509 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -132,6 +132,33 @@ def load_config(path: str | os.PathLike) -> dict: return obj +def repair_config_file(path: str | os.PathLike) -> tuple[dict, list[str], str]: + """ + Attempt to repair a config file that failed strict parsing, WITHOUT writing + anything to disk. The caller decides what to do with the result (BCC shows + the user the issues and asks before saving). + + Returns (cfg, notes, repaired_text): + cfg - the parsed dict from the repaired JSON + notes - human-readable list of fixes that were applied + repaired_text - pretty-printed JSON the config would become + + Raises ValueError if the file can't be salvaged automatically. + """ + text = Path(path).read_text(encoding="utf-8") + candidate, notes = repair_json_text(text) + try: + obj = json.loads(candidate) + except json.JSONDecodeError as e: + raise ValueError( + f"Couldn't repair the file automatically (line {e.lineno}, column {e.colno}: {e.msg})." + ) from e + if not isinstance(obj, dict): + raise ValueError("Even after repair, the top level isn't a JSON object.") + pretty = json.dumps(obj, indent=2, ensure_ascii=False) + "\n" + return obj, notes, pretty + + def extract_servers(cfg: dict) -> list[ServerEntry]: """Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers.""" out: list[ServerEntry] = [] diff --git a/tests/test_json_repair.py b/tests/test_json_repair.py index 7618b19..99d4a93 100644 --- a/tests/test_json_repair.py +++ b/tests/test_json_repair.py @@ -2,6 +2,8 @@ parse_pasted_json_verbose). Every case here is a shape people actually paste from MCP docs, blog posts, and chat windows.""" +import json + import pytest import bcc_core as c @@ -170,6 +172,35 @@ def test_kitchen_sink(): assert len(notes) >= 4 +# --------------------------------------------------------------------------- # +# Config-file repair (load-time) +# --------------------------------------------------------------------------- # +def test_repair_config_file_fixes_and_reports(tmp_path): + p = tmp_path / "claude_desktop_config.json" + p.write_text( + '{\n "globalShortcut": "Cmd+Space",\n' + " // my servers\n" + ' "mcpServers": {\n' + ' "brave": {"command": "npx", "args": ["-y"],},\n' + " },\n}" + ) + cfg, notes, preview = c.repair_config_file(p) + assert cfg["mcpServers"]["brave"]["command"] == "npx" + assert cfg["globalShortcut"] == "Cmd+Space" # unrelated keys survive + assert any("comment" in n for n in notes) + assert any("trailing" in n for n in notes) + assert json.loads(preview) == cfg + # nothing was written to disk + assert "//" in p.read_text() + + +def test_repair_config_file_hopeless_raises(tmp_path): + p = tmp_path / "claude_desktop_config.json" + p.write_text("total nonsense, no braces at all") + with pytest.raises(ValueError): + c.repair_config_file(p) + + # --------------------------------------------------------------------------- # # Still fails cleanly on hopeless input # --------------------------------------------------------------------------- #