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
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:
co-authored by
Claude Opus 4.8
parent
c5a6bdd1d1
commit
66b0101dea
@@ -49,8 +49,10 @@ from PySide6.QtWidgets import (
|
|||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QGridLayout,
|
QGridLayout,
|
||||||
|
QGroupBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QHeaderView,
|
QHeaderView,
|
||||||
QInputDialog,
|
QInputDialog,
|
||||||
@@ -63,6 +65,7 @@ from PySide6.QtWidgets import (
|
|||||||
QMessageBox,
|
QMessageBox,
|
||||||
QPlainTextEdit,
|
QPlainTextEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QStackedWidget,
|
QStackedWidget,
|
||||||
QStyledItemDelegate,
|
QStyledItemDelegate,
|
||||||
@@ -933,13 +936,23 @@ class ServerEditor(QFrame):
|
|||||||
v.addLayout(rf_row)
|
v.addLayout(rf_row)
|
||||||
# Shown when a server reads a config sidecar (ssh-mcp's TOML): the args
|
# 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
|
# 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
|
# reads. Advisory (#91); #102 adds an "Edit config…" button that opens
|
||||||
# a separate, deliberate action.
|
# the in-app sidecar editor whenever the server has a resolvable sidecar.
|
||||||
self.sidecar_warn = QLabel("")
|
self.sidecar_warn = QLabel("")
|
||||||
self.sidecar_warn.setStyleSheet(f"color: {WARN};")
|
self.sidecar_warn.setStyleSheet(f"color: {WARN};")
|
||||||
self.sidecar_warn.setWordWrap(True)
|
self.sidecar_warn.setWordWrap(True)
|
||||||
self.sidecar_warn.hide()
|
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
|
# 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.
|
# refuses to start unless it's 0600 / its dir 0700. One-click chmod fix.
|
||||||
# POSIX only — hidden on Windows where modes don't apply.
|
# 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_warn.hide()
|
||||||
self.removed_flag_fix_btn.hide()
|
self.removed_flag_fix_btn.hide()
|
||||||
self.sidecar_warn.hide()
|
self.sidecar_warn.hide()
|
||||||
|
self.sidecar_edit_btn.hide()
|
||||||
self.perm_warn.hide()
|
self.perm_warn.hide()
|
||||||
self.perm_fix_btn.hide()
|
self.perm_fix_btn.hide()
|
||||||
return
|
return
|
||||||
@@ -1202,6 +1216,9 @@ class ServerEditor(QFrame):
|
|||||||
self.sidecar_warn.show()
|
self.sidecar_warn.show()
|
||||||
else:
|
else:
|
||||||
self.sidecar_warn.hide()
|
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.
|
# Sidecar filesystem permissions (#93). Real platform/fs; no-op on Windows.
|
||||||
perm_warnings = core.sidecar_permission_warnings(stdio)
|
perm_warnings = core.sidecar_permission_warnings(stdio)
|
||||||
if perm_warnings:
|
if perm_warnings:
|
||||||
@@ -1248,6 +1265,35 @@ class ServerEditor(QFrame):
|
|||||||
return
|
return
|
||||||
self._check_args() # re-check; the warning clears when perms are now tight
|
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 ------------------------------------------------------ #
|
# --- dependency ------------------------------------------------------ #
|
||||||
def refresh_dependency(self, auto_open=False):
|
def refresh_dependency(self, auto_open=False):
|
||||||
if not self.isEnabled():
|
if not self.isEnabled():
|
||||||
@@ -2162,6 +2208,176 @@ class NoticeBanner(QFrame):
|
|||||||
self.show()
|
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
|
# 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.
|
# waiting for the old instance to exit, so it must run off the UI thread.
|
||||||
|
|||||||
Reference in New Issue
Block a user