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.
This commit is contained in:
@@ -632,6 +632,56 @@ class ArgsEdit(QPlainTextEdit):
|
|||||||
n += 1
|
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"<b>{path}</b><br>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
|
# Paste-JSON dialog
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
@@ -972,15 +1022,27 @@ class MainWindow(QMainWindow):
|
|||||||
return
|
return
|
||||||
if confirm and self.dirty and not self._confirm_discard():
|
if confirm and self.dirty and not self._confirm_discard():
|
||||||
return
|
return
|
||||||
|
repaired = False
|
||||||
try:
|
try:
|
||||||
self.full_config = core.load_config(profile.path)
|
self.full_config = core.load_config(profile.path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.critical(
|
# Strict parse failed — see if the repair pipeline can salvage it,
|
||||||
self,
|
# and let the user decide with full visibility of the changes.
|
||||||
"Could not read config",
|
try:
|
||||||
f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install.",
|
cfg, notes, preview = core.repair_config_file(profile.path)
|
||||||
)
|
except Exception:
|
||||||
return
|
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.current_profile = profile
|
||||||
self.servers = core.extract_servers(self.full_config)
|
self.servers = core.extract_servers(self.full_config)
|
||||||
self.dirty = False
|
self.dirty = False
|
||||||
@@ -988,6 +1050,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.undo_btn.setEnabled(False)
|
self.undo_btn.setEnabled(False)
|
||||||
self._refresh_tables(select_index=0 if self.servers else -1)
|
self._refresh_tables(select_index=0 if self.servers else -1)
|
||||||
self._update_status(saved=False)
|
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 ------------------------------------------------- #
|
# --- table rendering ------------------------------------------------- #
|
||||||
def _add_row(self, table, master_idx, s):
|
def _add_row(self, table, master_idx, s):
|
||||||
|
|||||||
+27
@@ -132,6 +132,33 @@ def load_config(path: str | os.PathLike) -> dict:
|
|||||||
return obj
|
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]:
|
def extract_servers(cfg: dict) -> list[ServerEntry]:
|
||||||
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers."""
|
"""Pull enabled (`mcpServers`) and disabled (`_disabledMcpServers`) servers."""
|
||||||
out: list[ServerEntry] = []
|
out: list[ServerEntry] = []
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
parse_pasted_json_verbose). Every case here is a shape people actually paste
|
parse_pasted_json_verbose). Every case here is a shape people actually paste
|
||||||
from MCP docs, blog posts, and chat windows."""
|
from MCP docs, blog posts, and chat windows."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import bcc_core as c
|
import bcc_core as c
|
||||||
@@ -170,6 +172,35 @@ def test_kitchen_sink():
|
|||||||
assert len(notes) >= 4
|
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
|
# Still fails cleanly on hopeless input
|
||||||
# --------------------------------------------------------------------------- #
|
# --------------------------------------------------------------------------- #
|
||||||
|
|||||||
Reference in New Issue
Block a user