@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user