diff --git a/bcc.py b/bcc.py
index 5b87889..18ece9f 100644
--- a/bcc.py
+++ b/bcc.py
@@ -49,8 +49,10 @@ from PySide6.QtWidgets import (
QDialog,
QDialogButtonBox,
QFileDialog,
+ QFormLayout,
QFrame,
QGridLayout,
+ QGroupBox,
QHBoxLayout,
QHeaderView,
QInputDialog,
@@ -63,6 +65,7 @@ from PySide6.QtWidgets import (
QMessageBox,
QPlainTextEdit,
QPushButton,
+ QSpinBox,
QSplitter,
QStackedWidget,
QStyledItemDelegate,
@@ -933,13 +936,23 @@ class ServerEditor(QFrame):
v.addLayout(rf_row)
# Shown when a server reads a config sidecar (ssh-mcp's TOML): the args
# may be inert, or a file may sit at the README path the server never
- # reads. Read-only advisory (#91) — no auto-fix; editing the sidecar is
- # a separate, deliberate action.
+ # reads. Advisory (#91); #102 adds an "Edit config…" button that opens
+ # the in-app sidecar editor whenever the server has a resolvable sidecar.
self.sidecar_warn = QLabel("")
self.sidecar_warn.setStyleSheet(f"color: {WARN};")
self.sidecar_warn.setWordWrap(True)
self.sidecar_warn.hide()
- v.addWidget(self.sidecar_warn)
+ self.sidecar_edit_btn = QPushButton("Edit config…")
+ self.sidecar_edit_btn.setToolTip(
+ "Open this server's external config file in BCC (edit → atomic save "
+ "with backup → the file is tightened to 0600)"
+ )
+ self.sidecar_edit_btn.clicked.connect(self._edit_sidecar)
+ self.sidecar_edit_btn.hide()
+ sc_row = QHBoxLayout()
+ sc_row.addWidget(self.sidecar_warn, 1)
+ sc_row.addWidget(self.sidecar_edit_btn, 0, Qt.AlignmentFlag.AlignTop)
+ v.addLayout(sc_row)
# Shown when the sidecar config is group/other-accessible (#93): ssh-mcp
# refuses to start unless it's 0600 / its dir 0700. One-click chmod fix.
# POSIX only — hidden on Windows where modes don't apply.
@@ -1160,6 +1173,7 @@ class ServerEditor(QFrame):
self.removed_flag_warn.hide()
self.removed_flag_fix_btn.hide()
self.sidecar_warn.hide()
+ self.sidecar_edit_btn.hide()
self.perm_warn.hide()
self.perm_fix_btn.hide()
return
@@ -1202,6 +1216,9 @@ class ServerEditor(QFrame):
self.sidecar_warn.show()
else:
self.sidecar_warn.hide()
+ # Offer the in-app editor (#102) whenever this server has a resolvable
+ # sidecar — even before the file exists, so it can be created from BCC.
+ self.sidecar_edit_btn.setVisible(core.sidecar_status(stdio) is not None)
# Sidecar filesystem permissions (#93). Real platform/fs; no-op on Windows.
perm_warnings = core.sidecar_permission_warnings(stdio)
if perm_warnings:
@@ -1248,6 +1265,35 @@ class ServerEditor(QFrame):
return
self._check_args() # re-check; the warning clears when perms are now tight
+ def _edit_sidecar(self):
+ """Open the in-app sidecar editor for this server's external config (#102)."""
+ stdio = {
+ "command": self.command.text().strip(),
+ "args": self._current_arg_lines(),
+ "env": self.env.dump(),
+ }
+ status = core.sidecar_status(stdio)
+ spec = core.resolve_server_spec(stdio)
+ if status is None or spec is None:
+ return
+ path = status["path"]
+ try:
+ text = path.read_text(encoding="utf-8") if status["exists"] else ""
+ except OSError as e:
+ QMessageBox.warning(self.window(), "Can't open config", f"{path}\n\n{e}")
+ return
+ dlg = SidecarEditorDialog(self.window(), spec.package, path, spec.schema, text)
+ if dlg.exec() and dlg.saved:
+ # Re-run detection through the same read-only path the #101 watcher
+ # uses, so "args inert" / permission advisories update live.
+ self._check_args()
+ bnote = f" · backup: {dlg.backup_path.name}" if dlg.backup_path else " · (new file)"
+ QMessageBox.information(
+ self.window(),
+ "Config saved",
+ f"Saved {path}{bnote}\nThe file was tightened to 0600.",
+ )
+
# --- dependency ------------------------------------------------------ #
def refresh_dependency(self, auto_open=False):
if not self.isEnabled():
@@ -2162,6 +2208,176 @@ class NoticeBanner(QFrame):
self.show()
+_UNSET_CHOICE = "— (unset) —" # sentinel entry for an enum pick-list
+
+
+class SidecarEditorDialog(QDialog):
+ """Edit an external sidecar config (ssh-mcp's config.toml) from inside BCC (#102).
+
+ A minimal, reviewable first pass: pick-lists for the schema enum fields and a
+ numeric spinner for the port, plus a raw-text editor that is the full,
+ always-available fallback. On Save the changed managed fields are applied
+ surgically on top of the raw text (so comments/unknown keys survive), the
+ managed values are validated against the ServerSpec schema, and the file is
+ written through core.write_sidecar (atomic + timestamped backup + chmod
+ 0600). All decision logic lives in bcc_core; this class is wiring.
+ """
+
+ def __init__(self, parent, package: str, path: Path, schema: dict, initial_text: str):
+ super().__init__(parent)
+ self.setWindowTitle(f"Edit {package} config")
+ self.resize(620, 620)
+ self._path = Path(path)
+ self._schema = schema or {}
+ self.backup_path: Path | None = None # set on a successful save
+ self.saved = False
+
+ v = QVBoxLayout(self)
+ info = QLabel(
+ f"Editing {path}
"
+ "Pick-lists cover the known settings; the raw editor below is the full "
+ "file. Saving writes atomically, keeps a timestamped backup, and tightens "
+ "the file to 0600."
+ )
+ info.setObjectName("muted")
+ info.setWordWrap(True)
+ info.setTextFormat(Qt.TextFormat.RichText)
+ v.addWidget(info)
+
+ # Which section the pick-lists target. ssh-mcp configs are section-based
+ # ([server] / per-profile); default to the first section, else top-level.
+ sec_row = QHBoxLayout()
+ sec_row.addWidget(QLabel("Section:"))
+ self.section_combo = QComboBox()
+ sections = core.toml_sections(initial_text) or [""]
+ for s in sections:
+ self.section_combo.addItem("(top level)" if s == "" else s, s)
+ # Prefer a section literally called "server" if present.
+ if "server" in sections:
+ self.section_combo.setCurrentIndex(sections.index("server"))
+ self.section_combo.currentIndexChanged.connect(self._reload_fields_from_raw)
+ sec_row.addWidget(self.section_combo, 1)
+ v.addLayout(sec_row)
+
+ # Managed schema fields.
+ self._fields_box = QGroupBox("Known settings")
+ self._form = QFormLayout(self._fields_box)
+ self._enum_widgets: dict[str, QComboBox] = {}
+ self._port_widget: QSpinBox | None = None
+ self._port_present = QCheckBox("set") # gate for whether port is written
+ self._build_fields()
+ v.addWidget(self._fields_box)
+
+ v.addWidget(QLabel("Raw TOML (full file — edit anything here):"))
+ self.raw = QPlainTextEdit()
+ self.raw.setPlainText(initial_text)
+ mono = self.raw.font()
+ mono.setFamily("Menlo, Consolas, monospace")
+ self.raw.setFont(mono)
+ v.addWidget(self.raw, 1)
+
+ self.err = QLabel("")
+ self.err.setStyleSheet(f"color: {BAD};")
+ self.err.setWordWrap(True)
+ self.err.hide()
+ v.addWidget(self.err)
+
+ btns = QDialogButtonBox()
+ self.save_btn = btns.addButton("Save", QDialogButtonBox.ButtonRole.AcceptRole)
+ self.save_btn.setObjectName("primary")
+ btns.addButton(QDialogButtonBox.StandardButton.Cancel)
+ btns.accepted.connect(self._save)
+ btns.rejected.connect(self.reject)
+ v.addWidget(btns)
+
+ self._reload_fields_from_raw()
+
+ def _current_section(self) -> str | None:
+ s = self.section_combo.currentData()
+ return None if s == "" else s
+
+ def _build_fields(self):
+ """Create a widget per schema key (enums -> combo, port range -> spin)."""
+ for key, rule in self._schema.items():
+ if isinstance(rule, list): # enum
+ combo = QComboBox()
+ combo.addItem(_UNSET_CHOICE, None)
+ for opt in rule:
+ combo.addItem(str(opt), opt)
+ self._enum_widgets[key] = combo
+ self._form.addRow(f"{key}:", combo)
+ elif isinstance(rule, dict) and "min" in rule and "max" in rule: # numeric range
+ row = QHBoxLayout()
+ spin = QSpinBox()
+ spin.setRange(int(rule["min"]), int(rule["max"]))
+ spin.setEnabled(False)
+ self._port_present.toggled.connect(spin.setEnabled)
+ row.addWidget(self._port_present)
+ row.addWidget(spin, 1)
+ holder = QWidget()
+ holder.setLayout(row)
+ self._port_widget = spin
+ self._form.addRow(f"{key}:", holder)
+
+ def _reload_fields_from_raw(self):
+ """Populate the pick-lists from the raw text for the chosen section, and
+ remember the loaded state so Save only applies fields the user changed."""
+ values = core.read_toml_section(self.raw.toPlainText(), self._current_section())
+ self._loaded: dict = {}
+ for key, combo in self._enum_widgets.items():
+ val = values.get(key)
+ idx = combo.findData(val) if val is not None else 0
+ combo.setCurrentIndex(idx if idx >= 0 else 0)
+ # An out-of-enum current value can't be shown; leave it at (unset) but
+ # DON'T record it as loaded so we never silently overwrite it on save.
+ self._loaded[key] = combo.currentData()
+ if self._port_widget is not None:
+ pv = values.get("port")
+ has = isinstance(pv, int) and not isinstance(pv, bool)
+ self._port_present.setChecked(has)
+ if has:
+ self._port_widget.setValue(pv)
+ self._loaded["port"] = pv if has else None
+
+ def _managed_updates(self) -> dict:
+ """The managed keys the user actually changed -> new value (None = delete).
+
+ Only changed fields are applied, so untouched keys (including any the
+ pick-list can't represent) are left exactly as the raw text has them.
+ """
+ updates: dict = {}
+ for key, combo in self._enum_widgets.items():
+ cur = combo.currentData()
+ if cur != self._loaded.get(key):
+ updates[key] = cur # None here means "delete the key"
+ if self._port_widget is not None:
+ cur = self._port_widget.value() if self._port_present.isChecked() else None
+ if cur != self._loaded.get("port"):
+ updates["port"] = cur
+ return updates
+
+ def _save(self):
+ section = self._current_section()
+ updates = self._managed_updates()
+ # Validate only the managed values the user is actually setting (a None =
+ # delete needs no enum check).
+ to_check = {k: v for k, v in updates.items() if v is not None}
+ problems = core.validate_sidecar_values(to_check, self._schema)
+ if problems:
+ self.err.setText("⚠ " + "\n".join(problems))
+ self.err.show()
+ return
+ try:
+ new_text = core.update_toml(self.raw.toPlainText(), updates, section=section)
+ self.backup_path = core.write_sidecar(self._path, new_text)
+ except Exception as e: # surface any write/permission failure in-place
+ self.err.setText(f"⚠ Could not save: {e}")
+ self.err.show()
+ return
+ self.saved = True
+ self.accept()
+
+
# --------------------------------------------------------------------------- #
# Restart worker: core.restart_claude_desktop() blocks up to ~5 s on macOS
# waiting for the old instance to exit, so it must run off the UI thread.