diff --git a/CLAUDE.md b/CLAUDE.md index 8713e97..7f20867 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,10 +8,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co # Install runtime dependency pip install -r requirements.txt python bcc.py # run the GUI -python test_core.py # run unit tests (23 tests, no GUI needed) + +# Test & lint (no GUI / PySide6 needed — tests only exercise bcc_core) +pip install -r requirements-dev.txt +python -m pytest # unit tests in tests/ +ruff check . # lint +ruff format . # format (CI enforces ruff format --check) # Build a self-contained binary -pip install -r requirements-dev.txt python scripts/build_icons.py # regenerate icons/app.icns + icons/app.ico if needed pyinstaller bcc.spec # macOS → dist/BetterClaudeConfig.app @@ -19,7 +23,7 @@ pyinstaller bcc.spec # Linux → dist/BetterClaudeConfig ``` -Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Build-time: `pyinstaller>=6.0`, `pillow>=10.0`. +Requires Python 3.10+. Runtime dependency: `PySide6>=6.6`. Tooling config (ruff, pytest, project metadata) lives in `pyproject.toml`. CI (`.github/workflows/ci.yml`) runs lint + tests on every push/PR; releases build on tag push (`release.yml`). ## Architecture @@ -29,7 +33,7 @@ The codebase is split into two layers: - `Profile` / `ServerEntry` dataclasses (the data model) - `discover_profiles()` — scans the platform's app-support directory for `Claude*` folders (Claude Desktop) **and** always adds `~/.claude/settings.json` (Claude Code, cross-platform) - `load_config` / `extract_servers` / `apply_servers` / `write_config` — the read/write pipeline; writes are atomic with rotating timestamped backups in `.bcc_backups/` -- `parse_pasted_json()` — accepts three JSON shapes (full config, inner map, or bare server object) +- `parse_pasted_json()` / `parse_pasted_json_verbose()` — accepts three JSON shapes (full config, inner map, or bare server object). Input does not have to be valid JSON: `repair_json_text()` auto-fixes markdown fences, surrounding prose, `//` `/* */` `#` comments, trailing/missing commas, smart quotes, single quotes, unquoted keys, Python/JS literals, and unclosed braces. The verbose variant also returns human-readable notes describing every repair applied (shown live in the paste dialog) - `check_dependency()` / `diagnostics_text()` / `pin_command_path()` — PATH resolution logic; distinguishes "found on normal PATH" (ok) vs "found only on augmented PATH" (warn) vs "not found" (missing). Uses an `lru_cache`-memoized `augmented_path()` that extends the inherited PATH with common runtime locations (nvm, homebrew, cargo, volta, etc.) - `test_remote()` — synchronous HTTP reachability check, intended to run off the UI thread diff --git a/README.md b/README.md index 05282bc..a9b4d08 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,12 @@ python bcc.py other file manually. - **Form-based editing** — name, command, args (one per line), env vars, or for remote servers: URL, transport, and headers. No raw JSON. -- **Paste JSON** — drop in any snippet from an MCP doc (full `mcpServers` block, - inner map, or a single bare server object); it's parsed and merged. +- **Paste JSON — even broken JSON** — drop in any snippet from an MCP doc (full + `mcpServers` block, inner map, or a single bare server object); it's parsed + and merged. The paste box parses as you type and auto-repairs the stuff docs + and chat windows love to break: markdown fences, surrounding prose, comments, + trailing or missing commas, smart quotes, single quotes, unquoted keys, and + unclosed braces — and tells you exactly what it fixed before you commit. - **Drag & drop** a `.json` file onto the window to import servers from it. - **Copy to ▸** — copy the selected server straight into your *other* install. - **Active / Disabled sections** — servers are shown in two labelled lists with diff --git a/bcc.py b/bcc.py index d6526ef..5416827 100644 --- a/bcc.py +++ b/bcc.py @@ -7,25 +7,43 @@ on PATH, backs up before writing, and never touches any other key in your config Run: python mcp_manager.py """ + from __future__ import annotations import sys from pathlib import Path -from PySide6.QtCore import Qt, QSize, QTimer, Signal, QThread -from PySide6.QtGui import QAction, QColor, QFont, QGuiApplication +from PySide6.QtCore import Qt, QThread, QTimer, Signal +from PySide6.QtGui import QAction, QColor, QGuiApplication from PySide6.QtWidgets import ( - QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, - QLabel, QPushButton, QComboBox, QLineEdit, QPlainTextEdit, QTableWidget, - QTableWidgetItem, QHeaderView, QStackedWidget, QFileDialog, QMessageBox, - QDialog, QDialogButtonBox, QAbstractItemView, QInputDialog, QFrame, - QSizePolicy, QMenu, QStyle, + QAbstractItemView, + QApplication, + QComboBox, + QDialog, + QDialogButtonBox, + QFileDialog, + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMainWindow, + QMenu, + QMessageBox, + QPlainTextEdit, + QPushButton, + QStackedWidget, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, ) import bcc_core as core # --- One-line rebrand: change this to recolor the whole app --------------- # -ACCENT = "#f97316" # warm orange +ACCENT = "#f97316" # warm orange ACCENT_DIM = "#c2570b" BG = "#1b1d23" PANEL = "#23262e" @@ -230,8 +248,8 @@ class ServerEditor(QFrame): super().__init__() self.setObjectName("card") self._on_change = on_change - self._before_change = before_change # called before any row add/remove in tables - self._extra = {} # preserve unknown server fields verbatim + self._before_change = before_change # called before any row add/remove in tables + self._extra = {} # preserve unknown server fields verbatim self._loading = False outer = QVBoxLayout(self) @@ -271,7 +289,9 @@ class ServerEditor(QFrame): self.dep_label = QLabel("—") self.dep_label.setObjectName("muted") self.fix_btn = QPushButton("Use full path ↳") - self.fix_btn.setToolTip("Rewrite the bare command to its absolute path so any launch of Claude can find it") + self.fix_btn.setToolTip( + "Rewrite the bare command to its absolute path so any launch of Claude can find it" + ) self.fix_btn.clicked.connect(self._pin_path) self.fix_btn.setVisible(False) self.test_btn = QPushButton("Test connection") @@ -314,9 +334,9 @@ class ServerEditor(QFrame): self.setEnabled(False) def _lbl(self, t): - l = QLabel(t) - l.setObjectName("muted") - return l + lbl = QLabel(t) + lbl.setObjectName("muted") + return lbl def _build_stdio_page(self): w = QWidget() @@ -330,13 +350,16 @@ class ServerEditor(QFrame): v.addWidget(self.command) v.addWidget(self._lbl("Arguments (one per line)")) self.args = QPlainTextEdit() - self.args.setPlaceholderText("-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents") + self.args.setPlaceholderText( + "-y\n@modelcontextprotocol/server-filesystem\n/Users/you/Documents" + ) self.args.setMaximumHeight(120) self.args.textChanged.connect(self._emit) v.addWidget(self.args) v.addWidget(self._lbl("Environment variables")) - self.env = KeyValueTable("Variable", "Value", on_change=self._emit, - before_change=self._before_change) + self.env = KeyValueTable( + "Variable", "Value", on_change=self._emit, before_change=self._before_change + ) v.addWidget(self.env) return w @@ -356,8 +379,9 @@ class ServerEditor(QFrame): self.transport.currentIndexChanged.connect(self._emit) v.addWidget(self.transport) v.addWidget(self._lbl("Headers")) - self.headers = KeyValueTable("Header", "Value", on_change=self._emit, - before_change=self._before_change) + self.headers = KeyValueTable( + "Header", "Value", on_change=self._emit, before_change=self._before_change + ) v.addWidget(self.headers) return w @@ -479,7 +503,9 @@ class ServerEditor(QFrame): self.diag_card.setVisible(on) self.details_btn.setText("Details ▾" if on else "Details ▸") if on and self.isEnabled(): - self.diag_text.setPlainText(core.diagnostics_text(self.current_name(), self.dump_data())) + self.diag_text.setPlainText( + core.diagnostics_text(self.current_name(), self.dump_data()) + ) def _recheck(self): core.refresh_path_cache() # re-scan PATH, e.g. after installing a runtime @@ -520,7 +546,7 @@ class ServerEditor(QFrame): color = GOOD if ok else BAD self.dep_dot.setText("●") self.dep_dot.setStyleSheet(f"color: {color}; font-size: 14px;") - self.dep_label.setText((f"reachable · {detail}" if ok else f"unreachable · {detail}")) + self.dep_label.setText(f"reachable · {detail}" if ok else f"unreachable · {detail}") self.dep_label.setStyleSheet(f"color: {color};") @@ -531,37 +557,72 @@ class PasteDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Paste MCP JSON") - self.resize(560, 380) + self.resize(560, 420) self.result_servers: dict[str, dict] | None = None v = QVBoxLayout(self) info = QLabel( - "Paste a config snippet from any MCP doc. Accepts a full " - "{\"mcpServers\": {...}} block, just the inner {\"name\": {...}} map, " - "or a single bare server object." + "Paste a config snippet from any MCP doc — it doesn't have to be " + "perfect JSON. Markdown fences, comments, trailing commas, smart " + "quotes, and missing braces are fixed automatically." ) info.setObjectName("muted") info.setWordWrap(True) v.addWidget(info) self.box = QPlainTextEdit() - self.box.setPlaceholderText('{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}') + self.box.setPlaceholderText( + '{\n "mcpServers": {\n "brave-search": {\n "command": "npx",\n "args": ["-y", "@modelcontextprotocol/server-brave-search"],\n "env": {"BRAVE_API_KEY": "..."}\n }\n }\n}' + ) v.addWidget(self.box, 1) self.err = QLabel("") - self.err.setStyleSheet(f"color: {BAD};") self.err.setWordWrap(True) v.addWidget(self.err) btns = QDialogButtonBox() self.parse_btn = btns.addButton("Parse & Add", QDialogButtonBox.ButtonRole.AcceptRole) self.parse_btn.setObjectName("primary") + self.parse_btn.setEnabled(False) btns.addButton(QDialogButtonBox.StandardButton.Cancel) btns.accepted.connect(self._try_parse) btns.rejected.connect(self.reject) v.addWidget(btns) + # Live parse-as-you-type, debounced so we don't churn on every keystroke. + self._debounce = QTimer(self) + self._debounce.setSingleShot(True) + self._debounce.setInterval(250) + self._debounce.timeout.connect(self._preview) + self.box.textChanged.connect(self._debounce.start) + + def _preview(self): + text = self.box.toPlainText() + if not text.strip(): + self.err.setText("") + self.parse_btn.setEnabled(False) + return + try: + servers, notes = core.parse_pasted_json_verbose(text) + except Exception as e: + self.err.setStyleSheet(f"color: {BAD};") + self.err.setText(str(e)) + self.parse_btn.setEnabled(False) + return + names = ", ".join( + f"{n} ({core.ServerEntry(n, d).kind})" for n, d in list(servers.items())[:6] + ) + if len(servers) > 6: + names += f", +{len(servers) - 6} more" + msg = f"✓ Will add: {names}" + if notes: + msg += "\nAuto-repaired: " + "; ".join(notes) + self.err.setStyleSheet(f"color: {GOOD};") + self.err.setText(msg) + self.parse_btn.setEnabled(True) + def _try_parse(self): try: self.result_servers = core.parse_pasted_json(self.box.toPlainText()) self.accept() except Exception as e: + self.err.setStyleSheet(f"color: {BAD};") self.err.setText(str(e)) @@ -584,7 +645,7 @@ class MainWindow(QMainWindow): self._suppress_sel = False self._focused_table = None self._row_of_index: dict[int, tuple] = {} - self._undo_stack: list[list] = [] # each entry: snapshot of self.servers + self._undo_stack: list[list] = [] # each entry: snapshot of self.servers central = QWidget() self.setCentralWidget(central) @@ -597,8 +658,7 @@ class MainWindow(QMainWindow): split = QHBoxLayout() split.setSpacing(12) split.addWidget(self._build_left(), 3) - self.editor = ServerEditor(on_change=self._editor_changed, - before_change=self._push_undo) + self.editor = ServerEditor(on_change=self._editor_changed, before_change=self._push_undo) split.addWidget(self.editor, 4) root.addLayout(split, 1) @@ -703,8 +763,14 @@ class MainWindow(QMainWindow): self.paste_btn.clicked.connect(self.paste_json) self.copy_btn.clicked.connect(self.copy_to_menu) self.undo_btn.clicked.connect(self._undo) - for b in (self.add_btn, self.dup_btn, self.del_btn, self.paste_btn, - self.copy_btn, self.undo_btn): + for b in ( + self.add_btn, + self.dup_btn, + self.del_btn, + self.paste_btn, + self.copy_btn, + self.undo_btn, + ): bar.addWidget(b) # Ctrl+Z shortcut undo_action = QAction(self) @@ -740,10 +806,8 @@ class MainWindow(QMainWindow): # --- profiles -------------------------------------------------------- # def reload_profiles(self): discovered = core.discover_profiles() - existing_paths = {str(p.path) for p in self.profiles} # keep any manually-added custom profiles around - customs = [p for p in self.profiles if str(p.path) not in - {str(d.path) for d in discovered}] + customs = [p for p in self.profiles if str(p.path) not in {str(d.path) for d in discovered}] self.profiles = discovered + customs self.profile_combo.blockSignals(True) self.profile_combo.clear() @@ -786,8 +850,9 @@ class MainWindow(QMainWindow): 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." + self, + "Could not read config", + f"{profile.path}\n\n{e}\n\nFix the file by hand or pick another install.", ) return self.current_profile = profile @@ -803,7 +868,11 @@ class MainWindow(QMainWindow): r = table.rowCount() table.insertRow(r) on = QTableWidgetItem() - on.setFlags(Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable) + on.setFlags( + Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEnabled + | Qt.ItemFlag.ItemIsSelectable + ) on.setCheckState(Qt.CheckState.Checked if s.enabled else Qt.CheckState.Unchecked) table.setItem(r, 0, on) name_item = QTableWidgetItem(s.name) @@ -816,8 +885,10 @@ class MainWindow(QMainWindow): type_item.setForeground(QColor(MUTED)) table.setItem(r, 2, type_item) dep = core.check_dependency(s.data) - st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'],'○')} {dep['label']}") - st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) # status stays colored even when off + st = QTableWidgetItem(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") + st.setForeground( + QColor(STATUS_COLORS.get(dep["status"], MUTED)) + ) # status stays colored even when off table.setItem(r, 3, st) self._row_of_index[master_idx] = (table, r) @@ -866,19 +937,25 @@ class MainWindow(QMainWindow): if s.enabled: n_a += 1 if st == "missing": - a_miss += 1; a_names.append(s.name) + a_miss += 1 + a_names.append(s.name) elif st == "warn": - a_warn += 1; a_names.append(s.name) + a_warn += 1 + a_names.append(s.name) else: n_d += 1 if st == "missing": - d_miss += 1; d_names.append(s.name) + d_miss += 1 + d_names.append(s.name) elif st == "warn": - d_warn += 1; d_names.append(s.name) + d_warn += 1 + d_names.append(s.name) self.active_label.setText(self._section_html("Active", n_a, a_miss, a_warn)) self.active_label.setToolTip(("Needs attention: " + ", ".join(a_names)) if a_names else "") self.disabled_label.setText(self._section_html("⊘ Disabled", n_d, d_miss, d_warn)) - self.disabled_label.setToolTip(("Needs attention: " + ", ".join(d_names)) if d_names else "") + self.disabled_label.setToolTip( + ("Needs attention: " + ", ".join(d_names)) if d_names else "" + ) def _select_in(self, table, row): self._suppress_sel = True @@ -958,7 +1035,7 @@ class MainWindow(QMainWindow): table.item(row, 2).setText("remote" if entry.kind == "remote" else "local") dep = core.check_dependency(entry.data) st = table.item(row, 3) - st.setText(f"{STATUS_GLYPH.get(dep['status'],'○')} {dep['label']}") + st.setText(f"{STATUS_GLYPH.get(dep['status'], '○')} {dep['label']}") st.setForeground(QColor(STATUS_COLORS.get(dep["status"], MUTED))) self._suppress_table = False self._refresh_badges() @@ -1001,7 +1078,10 @@ class MainWindow(QMainWindow): if not (0 <= idx < len(self.servers)): return name = self.servers[idx].name - if QMessageBox.question(self, "Delete server", f'Remove “{name}”?') != QMessageBox.StandardButton.Yes: + if ( + QMessageBox.question(self, "Delete server", f"Remove “{name}”?") + != QMessageBox.StandardButton.Yes + ): return self._push_undo() del self.servers[idx] @@ -1018,9 +1098,10 @@ class MainWindow(QMainWindow): for name, data in dlg.result_servers.items(): if name in existing: ans = QMessageBox.question( - self, "Server exists", - f'“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)', - QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + self, + "Server exists", + f"“{name}” already exists. Replace it?\n\nYes = replace · No = keep both (renamed)", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, ) if ans == QMessageBox.StandardButton.Yes: self.servers[existing[name]].data = data @@ -1066,8 +1147,9 @@ class MainWindow(QMainWindow): names = {s.name for s in existing} if src.name in names: ans = QMessageBox.question( - self, "Exists in destination", - f'“{src.name}” already exists in {dest.label}. Overwrite it there?' + self, + "Exists in destination", + f"“{src.name}” already exists in {dest.label}. Overwrite it there?", ) if ans != QMessageBox.StandardButton.Yes: return @@ -1080,7 +1162,9 @@ class MainWindow(QMainWindow): QMessageBox.critical(self, "Copy failed", f"Couldn't write {dest.label}:\n{e}") return bnote = f" (backup: {backup.name})" if backup else "" - self.status.setText(f'Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply.') + self.status.setText( + f"Copied “{src.name}” → {dest.label}{bnote}. Restart that Claude to apply." + ) # --- save ------------------------------------------------------------ # def _validate(self) -> bool: @@ -1129,14 +1213,18 @@ class MainWindow(QMainWindow): self.status.setText(f"{self.current_profile.path} · {on}/{n} enabled{d}") def _confirm_discard(self) -> bool: - return QMessageBox.question( - self, "Discard changes?", - "You have unsaved changes. Discard them?" - ) == QMessageBox.StandardButton.Yes + return ( + QMessageBox.question( + self, "Discard changes?", "You have unsaved changes. Discard them?" + ) + == QMessageBox.StandardButton.Yes + ) # --- drag & drop a .json file to import ------------------------------ # def dragEnterEvent(self, e): - if e.mimeData().hasUrls() and any(u.toLocalFile().endswith(".json") for u in e.mimeData().urls()): + if e.mimeData().hasUrls() and any( + u.toLocalFile().endswith(".json") for u in e.mimeData().urls() + ): e.acceptProposedAction() def dropEvent(self, e): @@ -1158,7 +1246,9 @@ class MainWindow(QMainWindow): self.servers.append(core.ServerEntry(name, data, True)) self._refresh_tables(select_index=len(self.servers) - 1) self._mark_dirty() - self.status.setText(f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save.") + self.status.setText( + f"Imported {len(servers)} server(s) from {Path(path).name}. Review and Save." + ) break def closeEvent(self, e): diff --git a/bcc_core.py b/bcc_core.py index 330ce61..397fc6f 100644 --- a/bcc_core.py +++ b/bcc_core.py @@ -10,17 +10,20 @@ ever touch the `mcpServers` block (and our own `_disabledMcpServers` parking key). Every other top-level key in the user's config is preserved verbatim and in its original position. """ + from __future__ import annotations +import contextlib import functools import glob import json import os +import re import shutil import sys import tempfile import time -from dataclasses import dataclass, field +from dataclasses import dataclass from pathlib import Path from urllib.parse import urlparse @@ -45,6 +48,7 @@ KNOWN_FIELDS = {"command", "args", "env", "url", "type", "headers"} @dataclass class Profile: """A discovered (or manually added) Claude install location.""" + label: str path: Path config_exists: bool @@ -171,10 +175,8 @@ def _make_backup(path: Path) -> Path: # Prune oldest beyond MAX_BACKUPS backups = sorted(bdir.glob(f"{path.stem}.*.json")) for old in backups[:-MAX_BACKUPS]: - try: + with contextlib.suppress(OSError): old.unlink() - except OSError: - pass return dest @@ -210,7 +212,7 @@ def looks_like_server(data) -> bool: def suggest_name(data: dict) -> str: """Derive a friendly default name from a server definition.""" if "url" in data and "command" not in data: - host = (urlparse(data["url"]).hostname or "remote") + host = urlparse(data["url"]).hostname or "remote" return host.split(".")[0] or "remote" for a in data.get("args", []) or []: if isinstance(a, str) and ("/" in a or a.startswith("@")): @@ -222,20 +224,295 @@ def suggest_name(data: dict) -> str: return Path(str(cmd)).stem or "server" -def parse_pasted_json(text: str) -> dict[str, dict]: - """ - Accept any of the shapes MCP docs hand out and return {name: server_dict}: +# --------------------------------------------------------------------------- # +# Lenient JSON repair +# +# Snippets pasted from MCP docs, blog posts, and chat windows are frequently +# not valid JSON: markdown fences, surrounding prose, // comments, trailing +# commas, smart quotes, single quotes, unquoted keys, missing braces. The +# whole point of BCC is that nobody should have to hand-fix JSON, so the +# paste pipeline repairs what it can and reports what it changed. +# --------------------------------------------------------------------------- # - 1. Full config: {"mcpServers": {"name": {...}}} - 2. Inner block only: {"name": {"command": ...}} - 3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) +# Word-processor / web artifacts mapped back to ASCII. +_QUOTE_MAP = { + "“": '"', + "”": '"', + "„": '"', + "«": '"', + "»": '"', + "‘": "'", + "’": "'", + "‚": "'", +} +_JUNK_CHARS = "​‌‍" # zero-width chars / BOM - Raises ValueError with a human message on anything unrecognizable. + +def _strip_fences(text: str, notes: list[str]) -> str: + """Pull the contents out of a ```json ... ``` fence (or drop stray fence lines).""" + m = re.search(r"```(?:json[c5]?|javascript|js)?\s*\n(.*?)```", text, re.DOTALL | re.IGNORECASE) + if m: + notes.append("extracted code from markdown fence") + return m.group(1) + if "```" in text: + notes.append("removed markdown fence markers") + return text.replace("```json", "").replace("```", "") + return text + + +def _normalize_unicode(text: str, notes: list[str]) -> str: + out = text + for junk in _JUNK_CHARS: + out = out.replace(junk, "") + out = out.replace(" ", " ") # non-breaking space + for smart, ascii_q in _QUOTE_MAP.items(): + out = out.replace(smart, ascii_q) + if out != text: + notes.append("normalized smart quotes / invisible characters") + return out + + +def _tokenize(text: str, notes: list[str]) -> list[tuple[bool, str]]: """ - text = (text or "").strip() - if not text: + One careful pass over the text producing (is_string, segment) pairs. + While walking it also: converts single-quoted strings to double-quoted, + and drops //, /* */ and # comments (never inside strings). + """ + segs: list[tuple[bool, str]] = [] + buf: list[str] = [] + i, n = 0, len(text) + + def flush(): + if buf: + segs.append((False, "".join(buf))) + buf.clear() + + while i < n: + ch = text[i] + if ch == '"': # proper double-quoted string: copy verbatim + flush() + j = i + 1 + s = ['"'] + while j < n: + c = text[j] + s.append(c) + if c == "\\" and j + 1 < n: + s.append(text[j + 1]) + j += 2 + continue + if c == '"': + j += 1 + break + j += 1 + segs.append((True, "".join(s))) + i = j + elif ch == "'": # single-quoted string: convert to double-quoted + flush() + j = i + 1 + inner: list[str] = [] + while j < n: + c = text[j] + if c == "\\" and j + 1 < n: + nxt = text[j + 1] + if nxt == "'": + inner.append("'") # \' has no meaning in JSON + else: + inner.append(c) + inner.append(nxt) + j += 2 + continue + if c == "'": + j += 1 + break + if c == '"': + inner.append('\\"') + j += 1 + continue + inner.append(c) + j += 1 + segs.append((True, '"' + "".join(inner) + '"')) + notes.append("converted single-quoted strings") + i = j + elif ch == "/" and i + 1 < n and text[i + 1] == "/": + notes.append("removed // comments") + while i < n and text[i] != "\n": + i += 1 + elif ch == "/" and i + 1 < n and text[i + 1] == "*": + notes.append("removed /* */ comments") + i += 2 + while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"): + i += 1 + i = min(i + 2, n) + elif ch == "#": + # Only treat as a comment at line start (after whitespace) — never + # mid-value, where # could be part of an unquoted token. + line_start = text.rfind("\n", 0, i) + 1 + if text[line_start:i].strip() == "": + notes.append("removed # comments") + while i < n and text[i] != "\n": + i += 1 + else: + buf.append(ch) + i += 1 + else: + buf.append(ch) + i += 1 + flush() + return segs + + +def _repair_segments(segs: list[tuple[bool, str]], notes: list[str]) -> str: + """Structural fixes that only apply outside strings.""" + fixed: list[str] = [] + for idx, (is_str, seg) in enumerate(segs): + if is_str: + fixed.append(seg) + continue + s = seg + # Python / JS literals -> JSON + s2 = re.sub(r"\bTrue\b", "true", s) + s2 = re.sub(r"\bFalse\b", "false", s2) + s2 = re.sub(r"\bNone\b|\bundefined\b", "null", s2) + if s2 != s: + notes.append("converted Python/JS literals (True/False/None)") + s = s2 + # Unquoted keys: { key: or , key: -> "key": + s2 = re.sub(r"([{,]\s*)([A-Za-z_$][\w$.-]*)(\s*:)", r'\1"\2"\3', s) + if s2 != s: + notes.append("quoted bare object keys") + s = s2 + # Missing comma before the next string on a new line, e.g. + # "args": ["x"]\n"env": {...} or {...}\n"name2": {...} + # A string directly after a value-ish ending across a newline can never + # be valid JSON without a comma, so inserting one is always safe. + if idx + 1 < len(segs) and segs[idx + 1][0]: + body = s.rstrip() + tail_ws = s[len(body) :] + prev = body[-1:] if body else (fixed[-1].rstrip()[-1:] if fixed else "") + value_ending = prev in ('"', "}", "]") or prev.isdigit() + if "\n" in tail_ws and value_ending: + notes.append("inserted missing commas") + s = body + "," + tail_ws + fixed.append(s) + + out = "".join(fixed) + # Trailing commas (safe now: strings are intact, commas here are structural) + segs2 = _tokenize(out, []) + parts: list[str] = [] + changed = False + for is_str, seg in segs2: + if is_str: + parts.append(seg) + else: + new = re.sub(r",(\s*[}\]])", r"\1", seg) + changed = changed or new != seg + parts.append(new) + if changed: + notes.append("removed trailing commas") + return "".join(parts) + + +def _extract_and_balance(text: str, notes: list[str]) -> str: + """Slice out the JSON object (dropping surrounding prose) and close any + unclosed braces/brackets.""" + start = text.find("{") + if start == -1: + return text + if text[:start].strip(): + notes.append("ignored text before the JSON block") + + stack: list[str] = [] + i, n = start, len(text) + end = -1 + while i < n: + ch = text[i] + if ch == '"': # skip strings + i += 1 + while i < n: + if text[i] == "\\": + i += 2 + continue + if text[i] == '"': + break + i += 1 + elif ch in "{[": + stack.append("}" if ch == "{" else "]") + elif ch in "}]": + if stack and stack[-1] == ch: + stack.pop() + if not stack: + end = i + 1 + break + i += 1 + + if end != -1: + if text[end:].strip(): + notes.append("ignored text after the JSON block") + return text[start:end] + # Ran out of input with open scopes: close them. + if stack: + notes.append("closed unclosed braces/brackets") + return text[start:] + "".join(reversed(stack)) + return text[start:] + + +def repair_json_text(text: str) -> tuple[str, list[str]]: + """ + Best-effort repair of an almost-JSON snippet. Returns (candidate, notes) + where notes is a human-readable list of the fixes applied (deduplicated, + in order). Does NOT guarantee the result parses — callers still try + json.loads and surface its error if repair wasn't enough. + """ + notes: list[str] = [] + t = _strip_fences(text, notes) + t = _normalize_unicode(t, notes) + t = t.strip() + + # Brace-less inner fragment: "name": { ... } (with no outer braces). + # A quoted key at the start is a strong signal; a bare word only counts + # when followed by '{' so prose like "Note: ..." isn't swallowed. + if re.match(r'^\s*"[^"\n]+"\s*:\s*[{["]', t) or re.match(r"^\s*[A-Za-z_$][\w$.-]*\s*:\s*\{", t): + notes.append("wrapped fragment in braces") + t = "{" + t + "}" + + t = _extract_and_balance(t, notes) + segs = _tokenize(t, notes) + t = _repair_segments(segs, notes) + + # Re-balance in case comment/quote fixes exposed structure. + t = _extract_and_balance(t, []) + + seen: set[str] = set() + unique = [x for x in notes if not (x in seen or seen.add(x))] + return t, unique + + +def parse_pasted_json_verbose(text: str) -> tuple[dict[str, dict], list[str]]: + """ + Like parse_pasted_json, but forgiving. Tries strict JSON first; if that + fails, runs repair_json_text() and retries. Returns (servers, repair_notes). + repair_notes is empty when the input was already valid. + """ + raw = (text or "").strip() + if not raw: raise ValueError("Nothing to parse.") - obj = json.loads(text) + try: + return _shape_servers(json.loads(raw)), [] + except json.JSONDecodeError as strict_err: + candidate, notes = repair_json_text(raw) + try: + obj = json.loads(candidate) + except json.JSONDecodeError: + # Repair wasn't enough — report the original, more meaningful error. + raise ValueError( + f"Couldn't parse that as JSON even after auto-repair " + f"(line {strict_err.lineno}, column {strict_err.colno}: {strict_err.msg})." + ) from strict_err + return _shape_servers(obj), notes + + +def _shape_servers(obj) -> dict[str, dict]: + """Shared shape-detection for parsed paste content.""" if not isinstance(obj, dict): raise ValueError("Top-level JSON must be an object ({ ... }).") @@ -252,13 +529,30 @@ def parse_pasted_json(text: str) -> dict[str, dict]: for name, data in servers.items(): if not looks_like_server(data): raise ValueError( - f"'{name}' doesn't look like an MCP server " - f"(it needs a 'command' or a 'url')." + f"'{name}' doesn't look like an MCP server (it needs a 'command' or a 'url')." ) clean[str(name)] = data return clean +def parse_pasted_json(text: str) -> dict[str, dict]: + """ + Accept any of the shapes MCP docs hand out and return {name: server_dict}: + + 1. Full config: {"mcpServers": {"name": {...}}} + 2. Inner block only: {"name": {"command": ...}} + 3. A bare server obj: {"command": ..., "args": [...]} (name is suggested) + + Input doesn't have to be valid JSON — markdown fences, comments, trailing + commas, smart/single quotes, unquoted keys, surrounding prose, and missing + braces are repaired automatically (see repair_json_text). + + Raises ValueError with a human message on anything unrecognizable. + """ + servers, _notes = parse_pasted_json_verbose(text) + return servers + + # --------------------------------------------------------------------------- # # Validation # --------------------------------------------------------------------------- # @@ -308,26 +602,30 @@ def system_path() -> str: dirs: list[str] = [] if sys.platform == "darwin": - for p in ["/etc/paths"] + sorted(glob.glob("/etc/paths.d/*")): - try: + for p in ["/etc/paths", *sorted(glob.glob("/etc/paths.d/*"))]: + with contextlib.suppress(OSError): dirs.extend(ln.strip() for ln in Path(p).read_text().splitlines() if ln.strip()) - except OSError: - pass # Package-manager install prefixes: present on disk once installed, # accessible to all processes regardless of shell configuration. for d in ( - "/opt/homebrew/bin", "/opt/homebrew/sbin", # Homebrew (Apple Silicon) - "/usr/local/bin", "/usr/local/sbin", # Homebrew (Intel) / manual installs - "/opt/local/bin", "/opt/local/sbin", # MacPorts + "/opt/homebrew/bin", + "/opt/homebrew/sbin", # Homebrew (Apple Silicon) + "/usr/local/bin", + "/usr/local/sbin", # Homebrew (Intel) / manual installs + "/opt/local/bin", + "/opt/local/sbin", # MacPorts ): dirs.append(d) elif os.name == "nt": try: import winreg + for hive, sub in [ - (winreg.HKEY_LOCAL_MACHINE, - r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"), + ( + winreg.HKEY_LOCAL_MACHINE, + r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ), (winreg.HKEY_CURRENT_USER, r"Environment"), ]: try: @@ -338,19 +636,23 @@ def system_path() -> str: pass except ImportError: pass - dirs.extend(["C:\\Windows\\System32", "C:\\Windows", - "C:\\Windows\\System32\\Wbem", - os.path.expandvars(r"%ProgramFiles%\nodejs")]) + dirs.extend( + [ + "C:\\Windows\\System32", + "C:\\Windows", + "C:\\Windows\\System32\\Wbem", + os.path.expandvars(r"%ProgramFiles%\nodejs"), + ] + ) else: # Linux / other try: for line in Path("/etc/environment").read_text().splitlines(): if line.upper().startswith("PATH="): - dirs.extend(line[5:].strip('"\'').split(os.pathsep)) + dirs.extend(line[5:].strip("\"'").split(os.pathsep)) except OSError: pass - for d in ("/usr/local/bin", "/usr/bin", "/bin", - "/usr/sbin", "/sbin", "/snap/bin"): + for d in ("/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", "/sbin", "/snap/bin"): dirs.append(d) seen, out = set(), [] @@ -384,7 +686,9 @@ def augmented_path() -> str: str(home / ".volta" / "bin"), ] user_dirs += glob.glob(str(home / ".nvm" / "versions" / "node" / "*" / "bin")) - user_dirs += glob.glob(str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin")) + user_dirs += glob.glob( + str(home / ".local" / "share" / "fnm" / "node-versions" / "*" / "installation" / "bin") + ) if os.name == "nt": appdata = os.environ.get("APPDATA", "") if appdata: @@ -407,12 +711,12 @@ def refresh_path_cache(): # Tailored install advice keyed by the runner's basename. RUNNER_HINTS = { "npx": "Node.js / npx not found. Install Node from nodejs.org or `brew install node`. " - "With nvm, the binary lives at ~/.nvm/versions/node//bin.", + "With nvm, the binary lives at ~/.nvm/versions/node//bin.", "node": "Node.js not found. Install from nodejs.org or `brew install node`.", "uvx": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " - "(or `brew install uv`). uvx ships inside uv.", + "(or `brew install uv`). uvx ships inside uv.", "uv": "uv not found. Install with `curl -LsSf https://astral.sh/uv/install.sh | sh` " - "(or `brew install uv`).", + "(or `brew install uv`).", "python": "Python not found on this PATH.", "python3": "Python 3 not found on this PATH.", "docker": "Docker not found. Install Docker Desktop and make sure it's running.", @@ -428,8 +732,10 @@ def runner_hint(cmd: str) -> str: if base in RUNNER_HINTS: return RUNNER_HINTS[base] if ("/" in cmd) or ("\\" in cmd): - return (f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " - f"Check the path and that it's marked executable (chmod +x).") + return ( + f"'{cmd}' looks like a file path, but it doesn't exist or isn't executable. " + f"Check the path and that it's marked executable (chmod +x)." + ) return f"'{cmd}' was not found on PATH. Install it, or put the full path to the executable in 'command'." @@ -466,14 +772,29 @@ def check_dependency(data: dict, path: str | None = None) -> dict: Returns: {status, label, resolved, in_base, searched_path, hints, commands, detail} """ if "url" in data and "command" not in data: - return {"status": "remote", "label": "remote endpoint", "detail": data.get("url", ""), - "resolved": {}, "in_base": {}, "searched_path": "", "hints": [], "commands": []} + return { + "status": "remote", + "label": "remote endpoint", + "detail": data.get("url", ""), + "resolved": {}, + "in_base": {}, + "searched_path": "", + "hints": [], + "commands": [], + } cmds = _commands_to_check(data) if not cmds: - return {"status": "unknown", "label": "no command set", "detail": "", - "resolved": {}, "in_base": {}, "searched_path": "", - "hints": ["This server has no 'command' to run."], "commands": []} + return { + "status": "unknown", + "label": "no command set", + "detail": "", + "resolved": {}, + "in_base": {}, + "searched_path": "", + "hints": ["This server has no 'command' to run."], + "commands": [], + } aug = path or augmented_path() resolved: dict[str, str | None] = {} @@ -515,8 +836,16 @@ def check_dependency(data: dict, path: str | None = None) -> dict: status = "ok" label = "found: " + ", ".join(Path(p).name for p in resolved.values()) - return {"status": status, "label": label, "resolved": resolved, "in_base": in_base, - "searched_path": aug, "hints": hints, "commands": cmds, "detail": ""} + return { + "status": status, + "label": label, + "resolved": resolved, + "in_base": in_base, + "searched_path": aug, + "hints": hints, + "commands": cmds, + "detail": "", + } def diagnostics_text(name: str, data: dict) -> str: @@ -563,8 +892,10 @@ def diagnostics_text(name: str, data: dict) -> str: L.append((" + " if d in extra else " ") + d) if extra: L.append("") - L.append("(+ = user-specific dirs not in the standard system PATH. " - "Claude Desktop launched from Finder may NOT have these.)") + L.append( + "(+ = user-specific dirs not in the standard system PATH. " + "Claude Desktop launched from Finder may NOT have these.)" + ) return "\n".join(L) @@ -582,9 +913,12 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]: if not (url.startswith("http://") or url.startswith("https://")): return False, "not an http(s) URL" req = urllib.request.Request( - url, method="GET", - headers={"Accept": "text/event-stream, application/json, */*", - "User-Agent": "BetterClaudeConfig/1.0"}, + url, + method="GET", + headers={ + "Accept": "text/event-stream, application/json, */*", + "User-Agent": "BetterClaudeConfig/1.0", + }, ) try: with urllib.request.urlopen(req, timeout=timeout) as r: @@ -596,9 +930,9 @@ def test_remote(url: str, timeout: float = 5.0) -> tuple[bool, str]: if isinstance(reason, socket.timeout): return False, "timed out" return False, str(reason) - except socket.timeout: + except TimeoutError: return False, "timed out" - except Exception as e: # noqa: BLE001 - surface anything else as a clean message + except Exception as e: return False, str(e) diff --git a/tests/test_json_repair.py b/tests/test_json_repair.py new file mode 100644 index 0000000..7618b19 --- /dev/null +++ b/tests/test_json_repair.py @@ -0,0 +1,188 @@ +"""Tests for the lenient JSON repair pipeline (repair_json_text + +parse_pasted_json_verbose). Every case here is a shape people actually paste +from MCP docs, blog posts, and chat windows.""" + +import pytest + +import bcc_core as c + +GOOD = {"command": "npx", "args": ["-y", "@x/brave"]} + + +def assert_brave(servers): + assert "brave" in servers + assert servers["brave"]["command"] == "npx" + + +# --------------------------------------------------------------------------- # +# Valid JSON passes through untouched +# --------------------------------------------------------------------------- # +def test_strict_json_no_notes(): + servers, notes = c.parse_pasted_json_verbose('{"brave": {"command": "npx", "args": []}}') + assert_brave(servers) + assert notes == [] + + +# --------------------------------------------------------------------------- # +# Markdown / prose wrappers +# --------------------------------------------------------------------------- # +def test_markdown_fence(): + text = 'Add this to your config:\n```json\n{"brave": {"command": "npx"}}\n```\nThen restart.' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("fence" in n for n in notes) + + +def test_fence_without_language_tag(): + text = '```\n{"brave": {"command": "npx"}}\n```' + servers, _ = c.parse_pasted_json_verbose(text) + assert_brave(servers) + + +def test_prose_around_bare_json(): + text = 'Paste the following: {"brave": {"command": "npx"}} and restart Claude.' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("before" in n or "after" in n for n in notes) + + +# --------------------------------------------------------------------------- # +# Comments +# --------------------------------------------------------------------------- # +def test_line_comments(): + text = '{\n // the search server\n "brave": {"command": "npx"}\n}' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("//" in n for n in notes) + + +def test_block_comments(): + text = '{ /* config */ "brave": {"command": "npx"} }' + servers, _ = c.parse_pasted_json_verbose(text) + assert_brave(servers) + + +def test_url_with_double_slash_is_not_a_comment(): + text = '{"remote": {"url": "https://mcp.example.com/sse"}}' + servers, notes = c.parse_pasted_json_verbose(text) + assert servers["remote"]["url"] == "https://mcp.example.com/sse" + assert notes == [] + + +# --------------------------------------------------------------------------- # +# Commas +# --------------------------------------------------------------------------- # +def test_trailing_comma_object(): + text = '{"brave": {"command": "npx", "args": ["-y"],},}' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("trailing" in n for n in notes) + + +def test_missing_comma_between_servers(): + text = '{"brave": {"command": "npx"}\n"git": {"command": "uvx"}}' + servers, notes = c.parse_pasted_json_verbose(text) + assert "brave" in servers and "git" in servers + assert any("missing comma" in n for n in notes) + + +def test_missing_comma_between_string_fields(): + text = '{"brave": {"command": "npx"\n"args": ["-y"]}}' + servers, _ = c.parse_pasted_json_verbose(text) + assert servers["brave"]["args"] == ["-y"] + + +# --------------------------------------------------------------------------- # +# Quotes +# --------------------------------------------------------------------------- # +def test_smart_quotes(): + text = "{“brave”: {“command”: “npx”}}" + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("smart quotes" in n for n in notes) + + +def test_single_quotes(): + text = "{'brave': {'command': 'npx', 'args': ['-y']}}" + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("single-quoted" in n for n in notes) + + +def test_single_quotes_with_inner_double(): + text = """{'brave': {'command': 'npx', 'env': {'NOTE': 'say "hi"'}}}""" + servers, _ = c.parse_pasted_json_verbose(text) + assert servers["brave"]["env"]["NOTE"] == 'say "hi"' + + +# --------------------------------------------------------------------------- # +# JS-style laxness +# --------------------------------------------------------------------------- # +def test_unquoted_keys(): + text = '{brave: {command: "npx", args: ["-y"]}}' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("bare object keys" in n for n in notes) + + +def test_python_literals(): + text = '{"brave": {"command": "npx", "enabled": True, "extra": None}}' + servers, notes = c.parse_pasted_json_verbose(text) + assert servers["brave"]["enabled"] is True + assert servers["brave"]["extra"] is None + assert any("literals" in n for n in notes) + + +# --------------------------------------------------------------------------- # +# Structure damage +# --------------------------------------------------------------------------- # +def test_braceless_fragment(): + text = '"brave": {"command": "npx", "args": ["-y"]}' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("wrapped" in n for n in notes) + + +def test_missing_closing_braces(): + text = '{"mcpServers": {"brave": {"command": "npx", "args": ["-y"]' + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert any("unclosed" in n for n in notes) + + +def test_kitchen_sink(): + # fence + comment + single quotes + unquoted key + trailing comma + prose + text = ( + "Here's the config you need:\n" + "```json\n" + "{\n" + " // brave search\n" + " brave: {\n" + " 'command': 'npx',\n" + " 'args': ['-y', '@x/brave',],\n" + " },\n" + "}\n" + "```\n" + ) + servers, notes = c.parse_pasted_json_verbose(text) + assert_brave(servers) + assert servers["brave"]["args"] == ["-y", "@x/brave"] + assert len(notes) >= 4 + + +# --------------------------------------------------------------------------- # +# Still fails cleanly on hopeless input +# --------------------------------------------------------------------------- # +def test_hopeless_input_raises_value_error(): + with pytest.raises(ValueError): + c.parse_pasted_json_verbose("this is just prose with no json at all") + + +def test_junk_object_still_rejected(): + with pytest.raises(ValueError): + c.parse_pasted_json_verbose('{"junk": 5}') + + +def test_empty_raises(): + with pytest.raises(ValueError): + c.parse_pasted_json_verbose(" ")