feat(#102): GUI — in-app sidecar editor (pick-lists + raw fallback)
CI / Lint (ruff) (pull_request) Successful in 7s
CI / Tests (py3.10 / ubuntu-latest) (pull_request) Successful in 12s
CI / Tests (py3.12 / windows-latest) (pull_request) Successful in 22s
CI / Tests (py3.12 / ubuntu-latest) (pull_request) Successful in 11s
CI / Tests (py3.13 / ubuntu-latest) (pull_request) Successful in 11s
CI / Catalog signature (pull_request) Successful in 7s

Reached via a new "Edit config…" button beside the sidecar advisory,
shown whenever the selected server has a resolvable sidecar (even before
the file exists, so it can be created from BCC).

SidecarEditorDialog — a minimal, reviewable first pass:
- A section picker (from core.toml_sections), defaulting to [server].
- Pick-lists for the schema enum fields (auth/approvalMode/role) and a
  range-bounded spinner for port — a layperson can't type auth="sshkey".
- A raw-TOML editor showing the full file: the always-available fallback
  for anything the form doesn't model.
- Save applies ONLY the fields the user changed, surgically on top of the
  raw text (core.update_toml), so comments/unknown keys/other sections
  round-trip; validates the changed managed values; writes through
  core.write_sidecar (atomic + backup + chmod 0600). Never touches
  apply_servers. On success it re-runs the read-only advisories (the same
  path #101's watcher uses) so "args inert"/permission advisories update
  live, and confirms the backup + 0600.

All decision logic is in bcc_core (unit-tested); this is thin wiring,
smoke-tested headlessly (QT_QPA_PLATFORM=offscreen): dialog build,
section detection, changed-field diff, surgical save with comment
preserved, 0600 applied, and Edit-button visibility (shown for ssh-mcp,
hidden for plain stdio + remote).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cowork Supervisor
2026-08-13 00:21:52 -04:00
co-authored by Claude Opus 4.8
parent c5a6bdd1d1
commit 66b0101dea
+219 -3
View File
@@ -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 <code>{path}</code><br>"
"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 <code>0600</code>."
)
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.