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.